From 59b1d4ba69579321aa8a93246c0b273f7ae99b84 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 30 Aug 2023 19:32:10 +0200 Subject: [PATCH 001/984] Bump version to 2023.10.0dev0 (#99349) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26811f31962..3e4772cfbbf 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: 4 - HA_SHORT_VERSION: 2023.9 + HA_SHORT_VERSION: 2023.10 DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 66d05f0bd4f..70f7827143b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,7 +6,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 9 +MINOR_VERSION: Final = 10 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 375aa7e5088..6ea19a77c4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.9.0.dev0" +version = "2023.10.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 8c04e4c7a36a6419c77b7ffda6142a2b5859af41 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 30 Aug 2023 19:56:34 +0200 Subject: [PATCH 002/984] Add explicit test of template config entry setup (#99345) --- .../snapshots/test_binary_sensor.ambr | 26 +++++++++ .../template/snapshots/test_sensor.ambr | 28 ++++++++++ .../components/template/test_binary_sensor.py | 51 ++++++++++++++++++ tests/components/template/test_sensor.py | 54 ++++++++++++++++++- 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 tests/components/template/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/template/snapshots/test_sensor.ambr diff --git a/tests/components/template/snapshots/test_binary_sensor.ambr b/tests/components/template/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2529021971a --- /dev/null +++ b/tests/components/template/snapshots/test_binary_sensor.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_setup_config_entry[config_entry_extra_options0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'binary_sensor.my_template', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_config_entry[config_entry_extra_options1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'binary_sensor.my_template', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/template/snapshots/test_sensor.ambr b/tests/components/template/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..7959959dfa9 --- /dev/null +++ b/tests/components/template/snapshots/test_sensor.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_setup_config_entry[config_entry_extra_options0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'sensor.my_template', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_setup_config_entry[config_entry_extra_options1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'My template', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_template', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }) +# --- diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index e43163f66fc..01c0f005716 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import setup from homeassistant.components import binary_sensor, template @@ -23,6 +24,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, assert_setup_component, async_fire_time_changed, mock_restore_cache, @@ -123,6 +125,55 @@ async def test_setup(hass: HomeAssistant, start_ha, entity_id) -> None: assert state.attributes["device_class"] == "motion" +@pytest.mark.parametrize( + "config_entry_extra_options", + [ + {}, + {"device_class": "battery"}, + ], +) +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry_extra_options: dict[str, str], +) -> None: + """Test the config flow.""" + state_template = ( + "{{ states('binary_sensor.one') == 'on' or " + " states('binary_sensor.two') == 'on' }}" + ) + input_entities = ["one", "two"] + input_states = {"one": "on", "two": "off"} + template_type = binary_sensor.DOMAIN + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", + input_states[input_entity], + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": state_template, + "template_type": template_type, + } + | config_entry_extra_options, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{template_type}.my_template") + assert state is not None + assert state == snapshot + + @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( ("config", "domain"), diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 5eca8330789..47e307bc6aa 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -4,9 +4,10 @@ from datetime import timedelta from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.bootstrap import async_from_config_dict -from homeassistant.components import sensor +from homeassistant.components import sensor, template from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_ICON, @@ -25,6 +26,7 @@ from homeassistant.setup import ATTR_COMPONENT, async_setup_component import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, assert_setup_component, async_fire_time_changed, mock_restore_cache_with_extra_data, @@ -33,6 +35,56 @@ from tests.common import ( TEST_NAME = "sensor.test_template_sensor" +@pytest.mark.parametrize( + "config_entry_extra_options", + [ + {}, + { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", + }, + ], +) +async def test_setup_config_entry( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry_extra_options: dict[str, str], +) -> None: + """Test the config flow.""" + state_template = "{{ float(states('sensor.one')) + float(states('sensor.two')) }}" + input_entities = ["one", "two"] + input_states = {"one": "10", "two": "20"} + template_type = sensor.DOMAIN + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", + input_states[input_entity], + {}, + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": state_template, + "template_type": template_type, + } + | config_entry_extra_options, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{template_type}.my_template") + assert state is not None + assert state == snapshot + + @pytest.mark.parametrize(("count", "domain"), [(1, sensor.DOMAIN)]) @pytest.mark.parametrize( "config", From 8a4e48532c6a6910dfb741b5ae6a1b1ff8757af0 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 30 Aug 2023 20:26:13 +0200 Subject: [PATCH 003/984] Bump pymodbus v3.5.0 (#99343) Bump pymodbus v3.5.0. --- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index d0d573227d8..a4187de77eb 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.4.1"] + "requirements": ["pymodbus==3.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 35394fafa95..034fcf8e94d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1851,7 +1851,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.4.1 +pymodbus==3.5.0 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40b6c4ace56..a193994946f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1370,7 +1370,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.4.1 +pymodbus==3.5.0 # homeassistant.components.monoprice pymonoprice==0.4 From 5fd88f5874b82a822cc56d7c39682b70e28d7a66 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 30 Aug 2023 14:56:19 -0400 Subject: [PATCH 004/984] Bump ZHA dependencies (#99341) * Bump ZHA dependencies * Include bellows as well --- homeassistant/components/zha/manifest.json | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4f23945b105..cd0dc2db5ae 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,12 +20,12 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.35.9", + "bellows==0.36.1", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.102", + "zha-quirks==0.0.103", "zigpy-deconz==0.21.0", - "zigpy==0.56.4", + "zigpy==0.57.0", "zigpy-xbee==0.18.1", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.4" diff --git a/requirements_all.txt b/requirements_all.txt index 034fcf8e94d..d3cbad4a535 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -509,7 +509,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.35.9 +bellows==0.36.1 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -2769,7 +2769,7 @@ zeroconf==0.88.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.102 +zha-quirks==0.0.103 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2790,7 +2790,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.56.4 +zigpy==0.57.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a193994946f..2100d064c78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -430,7 +430,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.35.9 +bellows==0.36.1 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -2039,7 +2039,7 @@ zeroconf==0.88.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.102 +zha-quirks==0.0.103 # homeassistant.components.zha zigpy-deconz==0.21.0 @@ -2054,7 +2054,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.56.4 +zigpy==0.57.0 # homeassistant.components.zwave_js zwave-js-server-python==0.51.0 From cdd22bf0fab012d6ba695350a298875bf31d7f32 Mon Sep 17 00:00:00 2001 From: b-uwe <61052367+b-uwe@users.noreply.github.com> Date: Wed, 30 Aug 2023 20:58:57 +0200 Subject: [PATCH 005/984] Revert "Remove the virtual integration for ultraloq" (#99302) Revert "Remove the virtual integration for ultraloq (#96355)" This reverts commit 56bc708b28d93ddc354593d5c02af3655febde6b. --- homeassistant/brands/u_tec.json | 2 +- homeassistant/components/ultraloq/__init__.py | 1 + homeassistant/components/ultraloq/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 13 ++++++++++--- 4 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/ultraloq/__init__.py create mode 100644 homeassistant/components/ultraloq/manifest.json diff --git a/homeassistant/brands/u_tec.json b/homeassistant/brands/u_tec.json index 2ce4be9a7d9..f0c2cf8a691 100644 --- a/homeassistant/brands/u_tec.json +++ b/homeassistant/brands/u_tec.json @@ -1,5 +1,5 @@ { "domain": "u_tec", "name": "U-tec", - "iot_standards": ["zwave"] + "integrations": ["ultraloq"] } diff --git a/homeassistant/components/ultraloq/__init__.py b/homeassistant/components/ultraloq/__init__.py new file mode 100644 index 00000000000..b650c59a5de --- /dev/null +++ b/homeassistant/components/ultraloq/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Ultraloq.""" diff --git a/homeassistant/components/ultraloq/manifest.json b/homeassistant/components/ultraloq/manifest.json new file mode 100644 index 00000000000..4775ba6caa3 --- /dev/null +++ b/homeassistant/components/ultraloq/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ultraloq", + "name": "Ultraloq", + "integration_type": "virtual", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ef496e7b58b..c357b5aed4c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5987,9 +5987,16 @@ }, "u_tec": { "name": "U-tec", - "iot_standards": [ - "zwave" - ] + "integrations": { + "ultraloq": { + "integration_type": "virtual", + "config_flow": false, + "iot_standards": [ + "zwave" + ], + "name": "Ultraloq" + } + } }, "ubiquiti": { "name": "Ubiquiti", From 03b1c7ad1deb4b9494df01b27ef8e1d7dfe179ba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 30 Aug 2023 21:11:52 +0200 Subject: [PATCH 006/984] Minor improvement in tests of hardware integrations (#99361) --- tests/components/hardkernel/test_init.py | 14 +++++++---- .../homeassistant_green/test_init.py | 11 ++++++--- .../homeassistant_yellow/test_init.py | 24 ++++++++++++------- tests/components/raspberry_pi/test_init.py | 11 ++++++--- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/tests/components/hardkernel/test_init.py b/tests/components/hardkernel/test_init.py index f202777f530..877a44a2ca2 100644 --- a/tests/components/hardkernel/test_init.py +++ b/tests/components/hardkernel/test_init.py @@ -26,7 +26,8 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: @@ -41,13 +42,17 @@ async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: title="Hardkernel", ) config_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 1 + with patch( "homeassistant.components.hardkernel.get_os_info", return_value={"board": "generic-x86-64"}, ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries()) == 0 async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: @@ -68,5 +73,6 @@ async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/homeassistant_green/test_init.py b/tests/components/homeassistant_green/test_init.py index f48aea3fdfb..0df7d918039 100644 --- a/tests/components/homeassistant_green/test_init.py +++ b/tests/components/homeassistant_green/test_init.py @@ -44,13 +44,17 @@ async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: title="Home Assistant Green", ) config_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 1 + with patch( "homeassistant.components.homeassistant_green.get_os_info", return_value={"board": "generic-x86-64"}, ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries()) == 0 async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: @@ -71,5 +75,6 @@ async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index c0e4165ba20..a785e46c8b2 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -37,7 +37,8 @@ async def test_setup_entry( ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 # Finish setting up ZHA if num_entries > 0: @@ -216,13 +217,17 @@ async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: title="Home Assistant Yellow", ) config_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 1 + with patch( "homeassistant.components.homeassistant_yellow.get_os_info", return_value={"board": "generic-x86-64"}, ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries()) == 0 async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: @@ -243,8 +248,9 @@ async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY async def test_setup_entry_addon_info_fails( @@ -269,8 +275,9 @@ async def test_setup_entry_addon_info_fails( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ): assert not await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_RETRY async def test_setup_entry_addon_not_running( @@ -295,5 +302,6 @@ async def test_setup_entry_addon_not_running( ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state == ConfigEntryState.SETUP_RETRY - start_addon.assert_called_once() + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + start_addon.assert_called_once() diff --git a/tests/components/raspberry_pi/test_init.py b/tests/components/raspberry_pi/test_init.py index 4bf64c7999a..b0e9ef89582 100644 --- a/tests/components/raspberry_pi/test_init.py +++ b/tests/components/raspberry_pi/test_init.py @@ -58,13 +58,17 @@ async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: title="Raspberry Pi", ) config_entry.add_to_hass(hass) + assert len(hass.config_entries.async_entries()) == 1 + with patch( "homeassistant.components.raspberry_pi.get_os_info", return_value={"board": "generic-x86-64"}, ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 + + assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries()) == 0 async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: @@ -85,5 +89,6 @@ async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: ) as mock_get_os_info: assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(mock_get_os_info.mock_calls) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY + + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY From 5819091af7a1f5dfd4d804aca2c289ceaa6a74b1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 30 Aug 2023 22:31:51 +0200 Subject: [PATCH 007/984] Move octoprint coordinator to its own file (#99359) Move octoprint coordinator to own file --- .coveragerc | 1 + .../components/octoprint/__init__.py | 82 +--------------- .../components/octoprint/coordinator.py | 93 +++++++++++++++++++ 3 files changed, 96 insertions(+), 80 deletions(-) create mode 100644 homeassistant/components/octoprint/coordinator.py diff --git a/.coveragerc b/.coveragerc index 97ed97ef293..0532e5ed0a1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -843,6 +843,7 @@ omit = homeassistant/components/obihai/connectivity.py homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py + homeassistant/components/octoprint/coordinator.py homeassistant/components/oem/climate.py homeassistant/components/ohmconnect/sensor.py homeassistant/components/ombi/* diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 07b2fa1a15d..5fd2182ca00 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -1,15 +1,11 @@ """Support for monitoring OctoPrint 3D printers.""" from __future__ import annotations -from datetime import timedelta import logging -from typing import cast import aiohttp -from pyoctoprintapi import ApiError, OctoprintClient, PrinterOffline -from pyoctoprintapi.exceptions import UnauthorizedException +from pyoctoprintapi import OctoprintClient import voluptuous as vol -from yarl import URL from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -27,15 +23,12 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify as util_slugify -import homeassistant.util.dt as dt_util from .const import DOMAIN +from .coordinator import OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -209,74 +202,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Octoprint data.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - octoprint: OctoprintClient, - config_entry: ConfigEntry, - interval: int, - ) -> None: - """Initialize.""" - super().__init__( - hass, - _LOGGER, - name=f"octoprint-{config_entry.entry_id}", - update_interval=timedelta(seconds=interval), - ) - self.config_entry = config_entry - self._octoprint = octoprint - self._printer_offline = False - self.data = {"printer": None, "job": None, "last_read_time": None} - - async def _async_update_data(self): - """Update data via API.""" - printer = None - try: - job = await self._octoprint.get_job_info() - except UnauthorizedException as err: - raise ConfigEntryAuthFailed from err - except ApiError as err: - raise UpdateFailed(err) from err - - # If octoprint is on, but the printer is disconnected - # printer will return a 409, so continue using the last - # reading if there is one - try: - printer = await self._octoprint.get_printer_info() - except PrinterOffline: - if not self._printer_offline: - _LOGGER.debug("Unable to retrieve printer information: Printer offline") - self._printer_offline = True - except UnauthorizedException as err: - raise ConfigEntryAuthFailed from err - except ApiError as err: - raise UpdateFailed(err) from err - else: - self._printer_offline = False - - return {"job": job, "printer": printer, "last_read_time": dt_util.utcnow()} - - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - unique_id = cast(str, self.config_entry.unique_id) - configuration_url = URL.build( - scheme=self.config_entry.data[CONF_SSL] and "https" or "http", - host=self.config_entry.data[CONF_HOST], - port=self.config_entry.data[CONF_PORT], - path=self.config_entry.data[CONF_PATH], - ) - - return DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer="OctoPrint", - name="OctoPrint", - configuration_url=str(configuration_url), - ) diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py new file mode 100644 index 00000000000..c6ce8fa66b7 --- /dev/null +++ b/homeassistant/components/octoprint/coordinator.py @@ -0,0 +1,93 @@ +"""The data update coordinator for OctoPrint.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import cast + +from pyoctoprintapi import ApiError, OctoprintClient, PrinterOffline +from pyoctoprintapi.exceptions import UnauthorizedException +from yarl import URL + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PATH, CONF_PORT, CONF_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Octoprint data.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + octoprint: OctoprintClient, + config_entry: ConfigEntry, + interval: int, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=f"octoprint-{config_entry.entry_id}", + update_interval=timedelta(seconds=interval), + ) + self.config_entry = config_entry + self._octoprint = octoprint + self._printer_offline = False + self.data = {"printer": None, "job": None, "last_read_time": None} + + async def _async_update_data(self): + """Update data via API.""" + printer = None + try: + job = await self._octoprint.get_job_info() + except UnauthorizedException as err: + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise UpdateFailed(err) from err + + # If octoprint is on, but the printer is disconnected + # printer will return a 409, so continue using the last + # reading if there is one + try: + printer = await self._octoprint.get_printer_info() + except PrinterOffline: + if not self._printer_offline: + _LOGGER.debug("Unable to retrieve printer information: Printer offline") + self._printer_offline = True + except UnauthorizedException as err: + raise ConfigEntryAuthFailed from err + except ApiError as err: + raise UpdateFailed(err) from err + else: + self._printer_offline = False + + return {"job": job, "printer": printer, "last_read_time": dt_util.utcnow()} + + @property + def device_info(self) -> DeviceInfo: + """Device info.""" + unique_id = cast(str, self.config_entry.unique_id) + configuration_url = URL.build( + scheme=self.config_entry.data[CONF_SSL] and "https" or "http", + host=self.config_entry.data[CONF_HOST], + port=self.config_entry.data[CONF_PORT], + path=self.config_entry.data[CONF_PATH], + ) + + return DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="OctoPrint", + name="OctoPrint", + configuration_url=str(configuration_url), + ) From cc9f0aaf80b325c760018d96a083730d65dddb0d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 30 Aug 2023 23:56:25 +0200 Subject: [PATCH 008/984] Escape core version [ci] (#99364) --- .github/workflows/ci.yaml | 2 +- script/version_bump.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3e4772cfbbf..bf6ba38ea91 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: 4 - HA_SHORT_VERSION: 2023.10 + HA_SHORT_VERSION: "2023.10" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" # 10.3 is the oldest supported version diff --git a/script/version_bump.py b/script/version_bump.py index 4c4f8a97f09..5e383ab7d4b 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -135,8 +135,8 @@ def write_ci_workflow(version: Version) -> None: short_version = ".".join(str(version).split(".", maxsplit=2)[:2]) content = re.sub( - r"(\n\W+HA_SHORT_VERSION: )\d{4}\.\d{1,2}\n", - f"\\g<1>{short_version}\n", + r"(\n\W+HA_SHORT_VERSION: )\"\d{4}\.\d{1,2}\"\n", + f'\\g<1>"{short_version}"\n', content, count=1, ) From 99a65fb45b764d3e1a8ded161ede4fbf135be579 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 30 Aug 2023 23:57:23 +0200 Subject: [PATCH 009/984] Collapse supported features list in Deconz (#99233) * Use shorthand attributes for Deconz * revert changes --- homeassistant/components/deconz/cover.py | 20 ++++++++++++-------- homeassistant/components/deconz/fan.py | 7 +------ homeassistant/components/deconz/light.py | 5 +++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 3eac9cafd52..012f064dd07 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -59,16 +59,20 @@ class DeconzCover(DeconzDevice[Cover], CoverEntity): """Set up cover device.""" super().__init__(cover := gateway.api.lights.covers[cover_id], gateway) - self._attr_supported_features = CoverEntityFeature.OPEN - self._attr_supported_features |= CoverEntityFeature.CLOSE - self._attr_supported_features |= CoverEntityFeature.STOP - self._attr_supported_features |= CoverEntityFeature.SET_POSITION + self._attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) if self._device.tilt is not None: - self._attr_supported_features |= CoverEntityFeature.OPEN_TILT - self._attr_supported_features |= CoverEntityFeature.CLOSE_TILT - self._attr_supported_features |= CoverEntityFeature.STOP_TILT - self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) self._attr_device_class = DECONZ_TYPE_TO_DEVICE_CLASS.get(cover.type) diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index a0d62126b92..278d702d63b 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -60,7 +60,7 @@ class DeconzFan(DeconzDevice[Light], FanEntity): def __init__(self, device: Light, gateway: DeconzGateway) -> None: """Set up fan.""" super().__init__(device, gateway) - + _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) if device.fan_speed in ORDERED_NAMED_FAN_SPEEDS: self._default_on_speed = device.fan_speed @@ -80,11 +80,6 @@ class DeconzFan(DeconzDevice[Light], FanEntity): ORDERED_NAMED_FAN_SPEEDS, self._device.fan_speed ) - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return len(ORDERED_NAMED_FAN_SPEEDS) - @callback def async_update_callback(self) -> None: """Store latest configured speed from the device.""" diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 46d10a77271..47ca1eda0d8 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -154,8 +154,9 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): self._attr_supported_color_modes.add(ColorMode.ONOFF) if device.brightness is not None: - self._attr_supported_features |= LightEntityFeature.FLASH - self._attr_supported_features |= LightEntityFeature.TRANSITION + self._attr_supported_features |= ( + LightEntityFeature.FLASH | LightEntityFeature.TRANSITION + ) if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT From 343e8f0ecc215798a3e3892ba63b964ce5b72cc1 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 30 Aug 2023 21:36:07 -0700 Subject: [PATCH 010/984] Opower MFA fixes (#99317) opower mfa fixes --- .../components/opower/config_flow.py | 24 +++++++++---------- .../components/opower/coordinator.py | 2 +- homeassistant/components/opower/manifest.json | 2 +- homeassistant/components/opower/strings.json | 11 ++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/opower/test_config_flow.py | 4 ++-- 7 files changed, 25 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 9f2ec56423d..d456fc536e5 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -5,7 +5,13 @@ from collections.abc import Mapping import logging from typing import Any -from opower import CannotConnect, InvalidAuth, Opower, get_supported_utility_names +from opower import ( + CannotConnect, + InvalidAuth, + Opower, + get_supported_utility_names, + select_utility, +) import voluptuous as vol from homeassistant import config_entries @@ -20,9 +26,7 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_UTILITY): vol.In( - get_supported_utility_names(supports_mfa=True) - ), + vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()), vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } @@ -38,7 +42,7 @@ async def _validate_login( login_data[CONF_UTILITY], login_data[CONF_USERNAME], login_data[CONF_PASSWORD], - login_data.get(CONF_TOTP_SECRET, None), + login_data.get(CONF_TOTP_SECRET), ) errors: dict[str, str] = {} try: @@ -50,12 +54,6 @@ async def _validate_login( return errors -@callback -def _supports_mfa(utility: str) -> bool: - """Return whether the utility supports MFA.""" - return utility not in get_supported_utility_names(supports_mfa=False) - - class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Opower.""" @@ -78,7 +76,7 @@ class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input[CONF_USERNAME], } ) - if _supports_mfa(user_input[CONF_UTILITY]): + if select_utility(user_input[CONF_UTILITY]).accepts_mfa(): self.utility_info = user_input return await self.async_step_mfa() @@ -154,7 +152,7 @@ class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): vol.Required(CONF_USERNAME): self.reauth_entry.data[CONF_USERNAME], vol.Required(CONF_PASSWORD): str, } - if _supports_mfa(self.reauth_entry.data[CONF_UTILITY]): + if select_utility(self.reauth_entry.data[CONF_UTILITY]).accepts_mfa(): schema[vol.Optional(CONF_TOTP_SECRET)] = str return self.async_show_form( step_id="reauth_confirm", diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 1410b62b7b6..5ce35e949af 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -55,7 +55,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): entry_data[CONF_UTILITY], entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD], - entry_data.get(CONF_TOTP_SECRET, None), + entry_data.get(CONF_TOTP_SECRET), ) async def _async_update_data( diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index fb4ff5153ec..05e89ea96d4 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.32"] + "requirements": ["opower==0.0.33"] } diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index ac931bf9308..362e6cd7596 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -5,8 +5,13 @@ "data": { "utility": "Utility name", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "TOTP Secret (only for some utilities, see documentation)" + "password": "[%key:common::config_flow::data::password%]" + } + }, + "mfa": { + "description": "The TOTP secret below is not one of the 6 digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", + "data": { + "totp_secret": "TOTP Secret" } }, "reauth_confirm": { @@ -14,7 +19,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "TOTP Secret (only for some utilities, see documentation)" + "totp_secret": "TOTP Secret" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index d3cbad4a535..3e165629bdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1374,7 +1374,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.32 +opower==0.0.33 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2100d064c78..d5a80dac336 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.32 +opower==0.0.33 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index 0391e42ca16..f9ae457a80e 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -300,7 +300,7 @@ async def test_form_valid_reauth( assert result["reason"] == "reauth_successful" await hass.async_block_till_done() - assert hass.config_entries.async_entries(DOMAIN)[0].data == { + assert mock_config_entry.data == { "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password2", @@ -350,7 +350,7 @@ async def test_form_valid_reauth_with_mfa( assert result["reason"] == "reauth_successful" await hass.async_block_till_done() - assert hass.config_entries.async_entries(DOMAIN)[0].data == { + assert mock_config_entry.data == { "utility": "Consolidated Edison (ConEd)", "username": "test-username", "password": "test-password2", From 70843862aa4b7e5508e6ba256fe65b6c5df93d00 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 31 Aug 2023 07:31:05 +0200 Subject: [PATCH 011/984] Address late review for bsblan (#99360) * Address late review comment * Break also comment --- homeassistant/components/bsblan/coordinator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index 9344500a118..15eff37e6db 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -35,7 +35,8 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[State]): LOGGER, name=f"{DOMAIN}_{config_entry.data[CONF_HOST]}", # use the default scan interval and add a random number of seconds to avoid timeouts when - # the BSB-Lan device is already/still busy retrieving data, e.g. for MQTT or internal logging. + # the BSB-Lan device is already/still busy retrieving data, + # e.g. for MQTT or internal logging. update_interval=SCAN_INTERVAL + timedelta(seconds=randint(1, 8)), ) @@ -50,5 +51,6 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[State]): return await self.client.state() except BSBLANConnectionError as err: raise UpdateFailed( - f"Error while establishing connection with BSB-Lan device at {self.config_entry.data[CONF_HOST]}" + f"Error while establishing connection with " + f"BSB-Lan device at {self.config_entry.data[CONF_HOST]}" ) from err From a41af4e6d39d0da6cf2ea11e8631d0aa70ccc923 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Aug 2023 03:33:57 -0400 Subject: [PATCH 012/984] Revert orjson to 3.9.2 (#99374) * Revert "Update orjson to 3.9.4 (#98108)" This reverts commit 3dd377cb2a0b60593a18767a5e4b032f5630fd78. * Revert "Update orjson to 3.9.3 (#97930)" This reverts commit d993aa59ea097b25084a5fde2730a576eb13b7b5. --- 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 949181c7ddd..0994ca657ba 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 mutagen==1.46.0 -orjson==3.9.4 +orjson==3.9.2 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.0.0 diff --git a/pyproject.toml b/pyproject.toml index 6ea19a77c4a..8f5b5c788fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==41.0.3", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.4", + "orjson==3.9.2", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index 10220697390..e7a3b0fc4c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.2.0 PyJWT==2.8.0 cryptography==41.0.3 pyOpenSSL==23.2.0 -orjson==3.9.4 +orjson==3.9.2 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 From e08661dad370b21ecc1e7cfdfeb7bc23cffcfcc9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 31 Aug 2023 17:45:44 +1000 Subject: [PATCH 013/984] Patch service validation in Aussie Broadband (#99077) * Bump pyAussieBB * rolling back to previous version * patching the pydantic 2.x issue in aussie_broadband integration * adding test for validate_service_type * adding test for validate_service_type * fixing tests, again * adding additional test * doing fixes for live tests * Implement Feedback * Add test to detect pydantic2 * Update test_init.py * Update docstring --------- Co-authored-by: James Hodgkinson --- .../components/aussie_broadband/__init__.py | 19 +++++++++++++++- .../components/aussie_broadband/test_init.py | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index ae4bc78580c..1bdb0579976 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -3,10 +3,11 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from aiohttp import ClientError from aussiebb.asyncio import AussieBB -from aussiebb.const import FETCH_TYPES +from aussiebb.const import FETCH_TYPES, NBN_TYPES, PHONE_TYPES from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType from homeassistant.config_entries import ConfigEntry @@ -22,6 +23,19 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] +# Backport for the pyaussiebb=0.0.15 validate_service_type method +def validate_service_type(service: dict[str, Any]) -> None: + """Check the service types against known types.""" + + if "type" not in service: + raise ValueError("Field 'type' not found in service data") + if service["type"] not in NBN_TYPES + PHONE_TYPES + ["Hardware"]: + raise UnrecognisedServiceType( + f"Service type {service['type']=} {service['name']=} - not recognised - ", + "please report this at https://github.com/yaleman/aussiebb/issues/new", + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Aussie Broadband from a config entry.""" # Login to the Aussie Broadband API and retrieve the current service list @@ -30,6 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], async_get_clientsession(hass), ) + # Overwrite the pyaussiebb=0.0.15 validate_service_type method with backport + # Required until pydantic 2.x is supported + client.validate_service_type = validate_service_type try: await client.login() services = await client.get_services(drop_types=FETCH_TYPES) diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index 3eb1972011c..dc32212ee87 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -3,8 +3,11 @@ from unittest.mock import patch from aiohttp import ClientConnectionError from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType +import pydantic +import pytest from homeassistant import data_entry_flow +from homeassistant.components.aussie_broadband import validate_service_type from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -19,6 +22,19 @@ async def test_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED +async def test_validate_service_type() -> None: + """Testing the validation function.""" + test_service = {"type": "Hardware", "name": "test service"} + validate_service_type(test_service) + + with pytest.raises(ValueError): + test_service = {"name": "test service"} + validate_service_type(test_service) + with pytest.raises(UnrecognisedServiceType): + test_service = {"type": "FunkyBob", "name": "test service"} + validate_service_type(test_service) + + async def test_auth_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" with patch( @@ -39,3 +55,9 @@ async def test_service_failure(hass: HomeAssistant) -> None: """Test init with a invalid service.""" entry = await setup_platform(hass, usage_effect=UnrecognisedServiceType()) assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_not_pydantic2() -> None: + """Test that Home Assistant still does not support Pydantic 2.""" + """For PR#99077 and validate_service_type backport""" + assert pydantic.__version__ < "2" From c25b3e55e49a502803a97f855c7f0cc4e5884970 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 31 Aug 2023 10:13:39 +0200 Subject: [PATCH 014/984] Add entity translations to Mill (#96541) --- homeassistant/components/mill/sensor.py | 26 +++++++++------------- homeassistant/components/mill/strings.json | 25 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 47b5b8c7b64..8c7c418e8ff 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -44,17 +44,17 @@ from .const import ( HEATER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CONSUMPTION_YEAR, + translation_key="year_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - name="Year consumption", ), SensorEntityDescription( key=CONSUMPTION_TODAY, + translation_key="day_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - name="Day consumption", ), ) @@ -63,21 +63,18 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Temperature", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, - name="Humidity", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=BATTERY, device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, - name="Battery", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -85,13 +82,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ECO2, device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - name="Estimated CO2", + translation_key="estimated_co2", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TVOC, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, - name="TVOC", + translation_key="tvoc", state_class=SensorStateClass.MEASUREMENT, ), ) @@ -99,22 +96,22 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( LOCAL_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="control_signal", + translation_key="control_signal", native_unit_of_measurement=PERCENTAGE, - name="Control signal", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current_power", + translation_key="current_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, - name="Current power", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="raw_ambient_temperature", + translation_key="uncalibrated_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Uncalibrated temperature", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), @@ -159,6 +156,8 @@ async def async_setup_entry( class MillSensor(CoordinatorEntity, SensorEntity): """Representation of a Mill Sensor device.""" + _attr_has_entity_name = True + def __init__(self, coordinator, entity_description, mill_device): """Initialize the sensor.""" super().__init__(coordinator) @@ -166,8 +165,6 @@ class MillSensor(CoordinatorEntity, SensorEntity): self._id = mill_device.device_id self.entity_description = entity_description self._available = False - - self._attr_name = f"{mill_device.name} {entity_description.name}" self._attr_unique_id = f"{mill_device.device_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mill_device.device_id)}, @@ -197,14 +194,13 @@ class MillSensor(CoordinatorEntity, SensorEntity): class LocalMillSensor(CoordinatorEntity, SensorEntity): """Representation of a Mill Sensor device.""" + _attr_has_entity_name = True + def __init__(self, coordinator, entity_description): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = entity_description - self._attr_name = ( - f"{coordinator.mill_data_connection.name} {entity_description.name}" - ) if mac := coordinator.mill_data_connection.mac_address: self._attr_unique_id = f"{mac}_{entity_description.key}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/mill/strings.json b/homeassistant/components/mill/strings.json index caeea189c0e..21e3e7a44a5 100644 --- a/homeassistant/components/mill/strings.json +++ b/homeassistant/components/mill/strings.json @@ -27,6 +27,31 @@ } } }, + "entity": { + "sensor": { + "year_consumption": { + "name": "Year consumption" + }, + "day_consumption": { + "name": "Day consumption" + }, + "estimated_co2": { + "name": "Estimated CO2" + }, + "tvoc": { + "name": "TVOC" + }, + "control_signal": { + "name": "Control signal" + }, + "current_power": { + "name": "Current power" + }, + "uncalibrated_temperature": { + "name": "Uncalibrated temperature" + } + } + }, "services": { "set_room_temperature": { "name": "Set room temperature", From 0fd9327c4679ecce4e2b8df8eac1d872c186baa6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 31 Aug 2023 10:24:03 +0200 Subject: [PATCH 015/984] Revert "Sonos add yaml config issue" (#99379) Revert "Sonos add yaml config issue (#97365)" This reverts commit 2299430dbeb470ff8b5a62fae1fa80fbfc3f014f. --- homeassistant/components/sonos/__init__.py | 23 +--------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 259a9f54044..e6b328cbcb0 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -25,13 +25,7 @@ from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - CALLBACK_TYPE, - DOMAIN as HOMEASSISTANT_DOMAIN, - Event, - HomeAssistant, - callback, -) +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -39,7 +33,6 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send 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.typing import ConfigType from .alarms import SonosAlarms @@ -132,20 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, context={"source": config_entries.SOURCE_IMPORT} ) ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Sonos", - }, - ) return True From 35560e01b9ac69517c0dab93860c1f7e306f91c3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Aug 2023 10:39:24 +0200 Subject: [PATCH 016/984] Add documentation URL for homeassistant_sky_connect (#99377) --- .../components/homeassistant_sky_connect/hardware.py | 3 ++- tests/components/homeassistant_sky_connect/test_hardware.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py index 217a6e57543..bd752278397 100644 --- a/homeassistant/components/homeassistant_sky_connect/hardware.py +++ b/homeassistant/components/homeassistant_sky_connect/hardware.py @@ -6,6 +6,7 @@ from homeassistant.core import HomeAssistant, callback from .const import DOMAIN +DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/" DONGLE_NAME = "Home Assistant SkyConnect" @@ -26,7 +27,7 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: description=entry.data["description"], ), name=DONGLE_NAME, - url=None, + url=DOCUMENTATION_URL, ) for entry in entries ] diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index 5ddddfc637b..ca9a7887040 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -78,7 +78,7 @@ async def test_hardware_info( "description": "bla_description", }, "name": "Home Assistant SkyConnect", - "url": None, + "url": "https://skyconnect.home-assistant.io/documentation/", }, { "board": None, @@ -91,7 +91,7 @@ async def test_hardware_info( "description": "bla_description_2", }, "name": "Home Assistant SkyConnect", - "url": None, + "url": "https://skyconnect.home-assistant.io/documentation/", }, ] } From 2c545ef3d2cf986e578fcfd29e66c10cdac090af Mon Sep 17 00:00:00 2001 From: Austin Brunkhorst Date: Thu, 31 Aug 2023 02:15:45 -0700 Subject: [PATCH 017/984] Update pysnooz to 0.8.6 (#99368) --- homeassistant/components/snooz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snooz/manifest.json b/homeassistant/components/snooz/manifest.json index cd132d5a175..5b43aa7e92d 100644 --- a/homeassistant/components/snooz/manifest.json +++ b/homeassistant/components/snooz/manifest.json @@ -14,5 +14,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/snooz", "iot_class": "local_push", - "requirements": ["pysnooz==0.8.3"] + "requirements": ["pysnooz==0.8.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3e165629bdf..a9908967d7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2035,7 +2035,7 @@ pysml==0.0.12 pysnmplib==5.0.21 # homeassistant.components.snooz -pysnooz==0.8.3 +pysnooz==0.8.6 # homeassistant.components.soma pysoma==0.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5a80dac336..2918dfae314 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1518,7 +1518,7 @@ pysml==0.0.12 pysnmplib==5.0.21 # homeassistant.components.snooz -pysnooz==0.8.3 +pysnooz==0.8.6 # homeassistant.components.soma pysoma==0.0.12 From 7ead5c44eada5caa36cd72fe14737b6deeefe3eb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 31 Aug 2023 12:37:21 +0200 Subject: [PATCH 018/984] Use shorthand attributes in iCloud (#99390) * Use shorthand attributes in iCloud * Use shorthand attributes in iCloud --- .../components/icloud/device_tracker.py | 6 +---- homeassistant/components/icloud/sensor.py | 24 +++++++------------ 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 0bd1dfb44a9..8513b47be2a 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -64,11 +64,7 @@ class IcloudTrackerEntity(TrackerEntity): self._account = account self._device = device self._unsub_dispatcher: CALLBACK_TYPE | None = None - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._device.unique_id + self._attr_unique_id = device.unique_id @property def location_accuracy(self): diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index e92a9ae4a8d..320c3f9f240 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -63,11 +63,14 @@ class IcloudDeviceBatterySensor(SensorEntity): self._account = account self._device = device self._unsub_dispatcher: CALLBACK_TYPE | None = None - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.unique_id}_battery" + self._attr_unique_id = f"{device.unique_id}_battery" + self._attr_device_info = DeviceInfo( + configuration_url="https://icloud.com/", + identifiers={(DOMAIN, device.unique_id)}, + manufacturer="Apple", + model=device.device_model, + name=device.name, + ) @property def native_value(self) -> int | None: @@ -87,17 +90,6 @@ class IcloudDeviceBatterySensor(SensorEntity): """Return default attributes for the iCloud device entity.""" return self._device.extra_state_attributes - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - configuration_url="https://icloud.com/", - identifiers={(DOMAIN, self._device.unique_id)}, - manufacturer="Apple", - model=self._device.device_model, - name=self._device.name, - ) - async def async_added_to_hass(self) -> None: """Register state update callback.""" self._unsub_dispatcher = async_dispatcher_connect( From 047f936d4c841da155094030e0b99dc0b07bd38d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 31 Aug 2023 14:29:24 +0200 Subject: [PATCH 019/984] Add entity component translation for water heater away mode attribute (#99394) --- homeassistant/components/water_heater/strings.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 5ddb61d28b0..6991d371bd3 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -16,6 +16,15 @@ "high_demand": "High Demand", "heat_pump": "Heat Pump", "performance": "Performance" + }, + "state_attributes": { + "away_mode": { + "name": "Away mode", + "state": { + "off": "Off", + "on": "On" + } + } } } }, From f36a300651819dd25f1a567e6ce1b8b05fba1bf3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Aug 2023 15:16:32 +0200 Subject: [PATCH 020/984] Improve template sensor config flow validation (#99373) --- .../components/template/config_flow.py | 27 +++++++--- tests/components/template/test_config_flow.py | 54 +++++++++++++++++-- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index b89b3cbc91d..b2ccddedad8 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -160,32 +160,43 @@ def _validate_unit(options: dict[str, Any]) -> None: and (units := DEVICE_CLASS_UNITS.get(device_class)) is not None and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units ): - units_string = sorted( - [str(unit) if unit else "no unit of measurement" for unit in units], + sorted_units = sorted( + [f"'{str(unit)}'" if unit else "no unit of measurement" for unit in units], key=str.casefold, ) + if len(sorted_units) == 1: + units_string = sorted_units[0] + else: + units_string = f"one of {', '.join(sorted_units)}" raise vol.Invalid( f"'{unit}' is not a valid unit for device class '{device_class}'; " - f"expected one of {', '.join(units_string)}" + f"expected {units_string}" ) def _validate_state_class(options: dict[str, Any]) -> None: """Validate state class.""" if ( - (device_class := options.get(CONF_DEVICE_CLASS)) + (state_class := options.get(CONF_STATE_CLASS)) + and (device_class := options.get(CONF_DEVICE_CLASS)) and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None - and (state_class := options.get(CONF_STATE_CLASS)) not in state_classes + and state_class not in state_classes ): - state_classes_string = sorted( - [str(state_class) for state_class in state_classes], + sorted_state_classes = sorted( + [f"'{str(state_class)}'" for state_class in state_classes], key=str.casefold, ) + if len(sorted_state_classes) == 0: + state_classes_string = "no state class" + elif len(sorted_state_classes) == 1: + state_classes_string = sorted_state_classes[0] + else: + state_classes_string = f"one of {', '.join(sorted_state_classes)}" raise vol.Invalid( f"'{state_class}' is not a valid state class for device class " - f"'{device_class}'; expected one of {', '.join(state_classes_string)}" + f"'{device_class}'; expected {state_classes_string}" ) diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index dd283ff9214..ba939f3b8d1 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -349,18 +349,62 @@ EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of tem [ ("binary_sensor", "{{", {}, {"state": EARLY_END_ERROR}), ("sensor", "{{", {}, {"state": EARLY_END_ERROR}), + ( + "sensor", + "", + {"device_class": "aqi", "unit_of_measurement": "cats"}, + { + "unit_of_measurement": ( + "'cats' is not a valid unit for device class 'aqi'; " + "expected no unit of measurement" + ), + }, + ), ( "sensor", "", {"device_class": "temperature", "unit_of_measurement": "cats"}, { - "state_class": ( - "'None' is not a valid state class for device class 'temperature'; " - "expected one of measurement" - ), "unit_of_measurement": ( "'cats' is not a valid unit for device class 'temperature'; " - "expected one of K, °C, °F" + "expected one of 'K', '°C', '°F'" + ), + }, + ), + ( + "sensor", + "", + {"device_class": "timestamp", "state_class": "measurement"}, + { + "state_class": ( + "'measurement' is not a valid state class for device class " + "'timestamp'; expected no state class" + ), + }, + ), + ( + "sensor", + "", + {"device_class": "aqi", "state_class": "total"}, + { + "state_class": ( + "'total' is not a valid state class for device class " + "'aqi'; expected 'measurement'" + ), + }, + ), + ( + "sensor", + "", + {"device_class": "energy", "state_class": "measurement"}, + { + "state_class": ( + "'measurement' is not a valid state class for device class " + "'energy'; expected one of 'total', 'total_increasing'" + ), + "unit_of_measurement": ( + "'None' is not a valid unit for device class 'energy'; " + "expected one of 'GJ', 'kWh', 'MJ', 'MWh', 'Wh'" ), }, ), From 875809a82750285f5cb3d19bbfc3873e6fc34545 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 31 Aug 2023 15:32:37 +0200 Subject: [PATCH 021/984] Update frontend to 20230831.0 (#99405) --- 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 06b6da85e19..a31faaf362e 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==20230830.0"] + "requirements": ["home-assistant-frontend==20230831.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0994ca657ba..3dccb80d11e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230830.0 +home-assistant-frontend==20230831.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a9908967d7a..fd5b63a84cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -994,7 +994,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230830.0 +home-assistant-frontend==20230831.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2918dfae314..efe3489d1e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -777,7 +777,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230830.0 +home-assistant-frontend==20230831.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 7042a02d72e8afff4771617900597da43dfd0a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 31 Aug 2023 16:43:32 +0200 Subject: [PATCH 022/984] Add remote alias to connection info response (#99410) --- homeassistant/components/cloud/client.py | 1 + tests/components/cloud/test_client.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 6fbcfc30f69..c216ec85c5c 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -221,6 +221,7 @@ class CloudClient(Interface): "connected": self.cloud.remote.is_connected, "enabled": self._prefs.remote_enabled, "instance_domain": self.cloud.remote.instance_domain, + "alias": self.cloud.remote.alias, }, "version": HA_VERSION, } diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 50cfce3f9a9..e205ba5f6e8 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -365,6 +365,11 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: response = await cloud.client.async_cloud_connection_info({}) assert response == { - "remote": {"connected": False, "enabled": False, "instance_domain": None}, + "remote": { + "connected": False, + "enabled": False, + "instance_domain": None, + "alias": None, + }, "version": HA_VERSION, } From 80caeafcb5b6e2f9da192d0ea6dd1a5b8244b743 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Aug 2023 16:50:53 +0200 Subject: [PATCH 023/984] Add documentation URL for homeassistant_yellow (#99336) * Add documentation URL for homeassistant_yellow * Fix test * Tweak --- homeassistant/components/homeassistant_yellow/hardware.py | 3 ++- tests/components/homeassistant_yellow/test_hardware.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_yellow/hardware.py b/homeassistant/components/homeassistant_yellow/hardware.py index b67eb50ff2c..0749ca8edc6 100644 --- a/homeassistant/components/homeassistant_yellow/hardware.py +++ b/homeassistant/components/homeassistant_yellow/hardware.py @@ -9,6 +9,7 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAME = "Home Assistant Yellow" +DOCUMENTATION_URL = "https://yellow.home-assistant.io/documentation/" MANUFACTURER = "homeassistant" MODEL = "yellow" @@ -39,6 +40,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: config_entries=config_entries, dongle=None, name=BOARD_NAME, - url=None, + url=DOCUMENTATION_URL, ) ] diff --git a/tests/components/homeassistant_yellow/test_hardware.py b/tests/components/homeassistant_yellow/test_hardware.py index 5fa0e73d82c..5fb662471aa 100644 --- a/tests/components/homeassistant_yellow/test_hardware.py +++ b/tests/components/homeassistant_yellow/test_hardware.py @@ -54,7 +54,7 @@ async def test_hardware_info( "config_entries": [config_entry.entry_id], "dongle": None, "name": "Home Assistant Yellow", - "url": None, + "url": "https://yellow.home-assistant.io/documentation/", } ] } From 22c50712701aaecf985c60818797e3371a01f5ad Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 31 Aug 2023 12:09:46 -0400 Subject: [PATCH 024/984] Initialize ZHA device database before connecting to the radio (#98082) * Create ZHA entities before attempting to connect to the coordinator * Delete the ZHA gateway object when unloading the config entry * Only load ZHA groups if the coordinator device info is known offline * Do not create a coordinator ZHA device until it is ready * [WIP] begin fixing unit tests * [WIP] Fix existing unit tests (one failure left) * Fix remaining unit test --- homeassistant/components/zha/__init__.py | 20 +---- homeassistant/components/zha/core/const.py | 1 - homeassistant/components/zha/core/gateway.py | 53 ++++++++++--- homeassistant/components/zha/core/helpers.py | 6 +- tests/components/zha/common.py | 10 +-- tests/components/zha/conftest.py | 26 ++++++- tests/components/zha/test_api.py | 2 +- tests/components/zha/test_gateway.py | 79 +++++++++++++------- tests/components/zha/test_websocket_api.py | 11 ++- 9 files changed, 133 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index a51d6f387e1..e48f8ce2096 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -10,7 +10,7 @@ from zhaquirks import setup as setup_quirks from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv @@ -33,7 +33,6 @@ from .core.const import ( DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY, - DATA_ZHA_SHUTDOWN_TASK, DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, @@ -137,6 +136,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_gateway = ZHAGateway(hass, config, config_entry) await zha_gateway.async_initialize() + config_entry.async_on_unload(zha_gateway.shutdown) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -149,15 +150,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b websocket_api.async_load_api(hass) - async def async_zha_shutdown(event): - """Handle shutdown tasks.""" - zha_gateway: ZHAGateway = zha_data[DATA_ZHA_GATEWAY] - await zha_gateway.shutdown() - - zha_data[DATA_ZHA_SHUTDOWN_TASK] = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, async_zha_shutdown - ) - await zha_gateway.async_initialize_devices_and_entities() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) @@ -167,12 +159,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" try: - zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY) + del hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] except KeyError: return False - await zha_gateway.shutdown() - GROUP_PROBE.cleanup() websocket_api.async_unload_api(hass) @@ -184,8 +174,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) ) - hass.data[DATA_ZHA][DATA_ZHA_SHUTDOWN_TASK]() - return True diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 7aab6112ab0..63b59e9d8d4 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -187,7 +187,6 @@ DATA_ZHA_CONFIG = "config" DATA_ZHA_BRIDGE_ID = "zha_bridge_id" DATA_ZHA_CORE_EVENTS = "zha_core_events" DATA_ZHA_GATEWAY = "zha_gateway" -DATA_ZHA_SHUTDOWN_TASK = "zha_shutdown_task" DEBUG_COMP_BELLOWS = "bellows" DEBUG_COMP_ZHA = "homeassistant.components.zha" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 1320e77ba3c..3abf1274f98 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -148,7 +148,6 @@ class ZHAGateway: self._log_relay_handler = LogRelayHandler(hass, self) self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] - self.initialized: bool = False def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" @@ -199,12 +198,32 @@ class ZHAGateway: self.ha_entity_registry = er.async_get(self._hass) app_controller_cls, app_config = self.get_application_controller_data() + self.application_controller = await app_controller_cls.new( + config=app_config, + auto_form=False, + start_radio=False, + ) + + self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self + + self.async_load_devices() + + # Groups are attached to the coordinator device so we need to load it early + coordinator = self._find_coordinator_device() + loaded_groups = False + + # We can only load groups early if the coordinator's model info has been stored + # in the zigpy database + if coordinator.model is not None: + self.coordinator_zha_device = self._async_get_or_create_device( + coordinator, restored=True + ) + self.async_load_groups() + loaded_groups = True for attempt in range(STARTUP_RETRIES): try: - self.application_controller = await app_controller_cls.new( - app_config, auto_form=True, start_radio=True - ) + await self.application_controller.startup(auto_form=True) except zigpy.exceptions.TransientConnectionError as exc: raise ConfigEntryNotReady from exc except Exception as exc: # pylint: disable=broad-except @@ -223,21 +242,33 @@ class ZHAGateway: else: break + self.coordinator_zha_device = self._async_get_or_create_device( + self._find_coordinator_device(), restored=True + ) + self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) + + # If ZHA groups could not load early, we can safely load them now + if not loaded_groups: + self.async_load_groups() + self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) - self.async_load_devices() - self.async_load_groups() - self.initialized = True + + def _find_coordinator_device(self) -> zigpy.device.Device: + if last_backup := self.application_controller.backups.most_recent_backup(): + zigpy_coordinator = self.application_controller.get_device( + ieee=last_backup.node_info.ieee + ) + else: + zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) + + return zigpy_coordinator @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) - if zha_device.ieee == self.coordinator_ieee: - self.coordinator_zha_device = zha_device delta_msg = "not known" if zha_device.last_seen is not None: delta = round(time.time() - zha_device.last_seen) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index ac7c15d3ecd..7b0d062738b 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -27,7 +27,6 @@ import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, State, callback -from homeassistant.exceptions import IntegrationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( @@ -246,11 +245,8 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: _LOGGER.error("Device id `%s` not found in registry", device_id) raise KeyError(f"Device id `{device_id}` not found in registry.") zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - if not zha_gateway.initialized: - _LOGGER.error("Attempting to get a ZHA device when ZHA is not initialized") - raise IntegrationError("ZHA is not initialized yet") try: - ieee_address = list(list(registry_device.identifiers)[0])[1] + ieee_address = list(registry_device.identifiers)[0][1] ieee = zigpy.types.EUI64.convert(ieee_address) except (IndexError, ValueError) as ex: _LOGGER.error( diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 01206c432e6..db1da3721ee 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -87,10 +87,7 @@ def update_attribute_cache(cluster): def get_zha_gateway(hass): """Return ZHA gateway from hass.data.""" - try: - return hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] - except KeyError: - return None + return hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] def make_attribute(attrid, value, status=0): @@ -167,12 +164,9 @@ def find_entity_ids(domain, zha_device, hass): def async_find_group_entity_id(hass, domain, group): """Find the group entity id under test.""" - entity_id = ( - f"{domain}.fakemanufacturer_fakemodel_{group.name.lower().replace(' ', '_')}" - ) + entity_id = f"{domain}.coordinator_manufacturer_coordinator_model_{group.name.lower().replace(' ', '_')}" entity_ids = hass.states.async_entity_ids(domain) - assert entity_id in entity_ids return entity_id diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index dd2c200973c..f690a5152fc 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -16,6 +16,8 @@ import zigpy.profiles import zigpy.quirks import zigpy.types import zigpy.util +from zigpy.zcl.clusters.general import Basic, Groups +from zigpy.zcl.foundation import Status import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.const as zha_const @@ -116,6 +118,9 @@ def zigpy_app_controller(): { zigpy.config.CONF_DATABASE: None, zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/null"}, + zigpy.config.CONF_STARTUP_ENERGY_SCAN: False, + zigpy.config.CONF_NWK_BACKUP_ENABLED: False, + zigpy.config.CONF_TOPO_SCAN_ENABLED: False, } ) @@ -128,9 +133,24 @@ def zigpy_app_controller(): app.state.network_info.channel = 15 app.state.network_info.network_key.key = zigpy.types.KeyData(range(16)) - with patch("zigpy.device.Device.request"), patch.object( - app, "permit", autospec=True - ), patch.object(app, "permit_with_key", autospec=True): + # Create a fake coordinator device + dev = app.add_device(nwk=app.state.node_info.nwk, ieee=app.state.node_info.ieee) + dev.node_desc = zdo_t.NodeDescriptor() + dev.node_desc.logical_type = zdo_t.LogicalType.Coordinator + dev.manufacturer = "Coordinator Manufacturer" + dev.model = "Coordinator Model" + + ep = dev.add_endpoint(1) + ep.add_input_cluster(Basic.cluster_id) + ep.add_input_cluster(Groups.cluster_id) + + with patch( + "zigpy.device.Device.request", return_value=[Status.SUCCESS] + ), patch.object(app, "permit", autospec=True), patch.object( + app, "startup", wraps=app.startup + ), patch.object( + app, "permit_with_key", autospec=True + ): yield app diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 85f85cc0437..c2cb16efcc8 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -71,7 +71,7 @@ async def test_async_get_network_settings_missing( await setup_zha() gateway = api._get_gateway(hass) - await zha.async_unload_entry(hass, gateway.config_entry) + await gateway.config_entry.async_unload(hass) # Network settings were never loaded for whatever reason zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index b9fcd4b6932..0f791a08955 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,9 +1,9 @@ """Test ZHA Gateway.""" import asyncio -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +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 @@ -232,68 +232,89 @@ async def test_gateway_create_group_with_id( ) @patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) @pytest.mark.parametrize( - "startup", + "startup_effect", [ - [asyncio.TimeoutError(), FileNotFoundError(), MagicMock()], - [asyncio.TimeoutError(), MagicMock()], - [MagicMock()], + [asyncio.TimeoutError(), FileNotFoundError(), None], + [asyncio.TimeoutError(), None], + [None], ], ) async def test_gateway_initialize_success( - startup: list[Any], + 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 - zha_gateway.shutdown = AsyncMock() + zigpy_app_controller.startup.side_effect = startup_effect + zigpy_app_controller.startup.reset_mock() with patch( - "bellows.zigbee.application.ControllerApplication.new", side_effect=startup - ) as mock_new: + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ): await zha_gateway.async_initialize() - assert mock_new.call_count == len(startup) - + 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, coordinator + 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", - side_effect=[asyncio.TimeoutError(), FileNotFoundError(), RuntimeError()], - ) as mock_new, pytest.raises(RuntimeError): + return_value=zigpy_app_controller, + ), pytest.raises(FileNotFoundError): await zha_gateway.async_initialize() - assert mock_new.call_count == 3 + 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, coordinator + 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", - side_effect=[RuntimeError(), zigpy.exceptions.TransientConnectionError()], - ) as mock_new, pytest.raises(ConfigEntryNotReady): + return_value=zigpy_app_controller, + ), pytest.raises(ConfigEntryNotReady): await zha_gateway.async_initialize() # Initialization immediately stops and is retried after TransientConnectionError - assert mock_new.call_count == 2 + assert zigpy_app_controller.startup.call_count == 2 @patch( @@ -313,7 +334,12 @@ async def test_gateway_initialize_failure_transient( ], ) async def test_gateway_initialize_bellows_thread( - device_path, thread_state, config_override, hass: HomeAssistant, coordinator + device_path: str, + thread_state: bool, + config_override: dict, + hass: HomeAssistant, + coordinator: ZHADevice, + zigpy_app_controller: ControllerApplication, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" zha_gateway = get_zha_gateway(hass) @@ -324,15 +350,12 @@ async def test_gateway_initialize_bellows_thread( zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) with patch( - "bellows.zigbee.application.ControllerApplication.new" - ) as controller_app_mock: - mock = AsyncMock() - mock.add_listener = MagicMock() - mock.groups = MagicMock() - controller_app_mock.return_value = mock + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: await zha_gateway.async_initialize() - assert controller_app_mock.mock_calls[0].args[0]["use_thread"] is thread_state + assert mock_new.mock_calls[0].kwargs["config"]["use_thread"] is thread_state @pytest.mark.parametrize( diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 0904fc1f685..740ffd6c06c 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -13,6 +13,7 @@ import zigpy.profiles.zha import zigpy.types from zigpy.types.named import EUI64 import zigpy.zcl.clusters.general as general +from zigpy.zcl.clusters.general import Groups import zigpy.zcl.clusters.security as security import zigpy.zdo.types as zdo_types @@ -233,7 +234,7 @@ async def test_list_devices(zha_client) -> None: msg = await zha_client.receive_json() devices = msg["result"] - assert len(devices) == 2 + assert len(devices) == 2 + 1 # the coordinator is included as well msg_id = 100 for device in devices: @@ -371,8 +372,13 @@ async def test_get_group_not_found(zha_client) -> None: assert msg["error"]["code"] == const.ERR_NOT_FOUND -async def test_list_groupable_devices(zha_client, device_groupable) -> None: +async def test_list_groupable_devices( + zha_client, device_groupable, zigpy_app_controller +) -> None: """Test getting ZHA devices that have a group cluster.""" + # Ensure the coordinator doesn't have a group cluster + coordinator = zigpy_app_controller.get_device(nwk=0x0000) + del coordinator.endpoints[1].in_clusters[Groups.cluster_id] await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"}) @@ -479,6 +485,7 @@ async def app_controller( ) -> ControllerApplication: """Fixture for zigpy Application Controller.""" await setup_zha() + zigpy_app_controller.permit.reset_mock() return zigpy_app_controller From 2e7018a1528b72bfc12373aa36a10b44607240ad Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 31 Aug 2023 19:39:17 +0200 Subject: [PATCH 025/984] Move tankerkoenig coordinator and base entity to its own file (#99416) * Move tankerkoenig coordinator and entity to its own file * Add coordinator.py and entity.py to .coveragerc --- .coveragerc | 2 + .../components/tankerkoenig/__init__.py | 130 +----------------- .../components/tankerkoenig/binary_sensor.py | 3 +- .../components/tankerkoenig/coordinator.py | 113 +++++++++++++++ .../components/tankerkoenig/entity.py | 25 ++++ .../components/tankerkoenig/sensor.py | 3 +- 6 files changed, 148 insertions(+), 128 deletions(-) create mode 100644 homeassistant/components/tankerkoenig/coordinator.py create mode 100644 homeassistant/components/tankerkoenig/entity.py diff --git a/.coveragerc b/.coveragerc index 0532e5ed0a1..12659c47092 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1275,6 +1275,8 @@ omit = homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/__init__.py homeassistant/components/tankerkoenig/binary_sensor.py + homeassistant/components/tankerkoenig/coordinator.py + homeassistant/components/tankerkoenig/entity.py homeassistant/components/tankerkoenig/sensor.py homeassistant/components/tapsaff/binary_sensor.py homeassistant/components/tautulli/__init__.py diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 39ae0c2fc16..ac93154388a 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -1,26 +1,18 @@ """Ask tankerkoenig.de for petrol price information.""" from __future__ import annotations -from datetime import timedelta import logging -from math import ceil -import pytankerkoenig from requests.exceptions import RequestException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ID, CONF_API_KEY, CONF_SHOW_ON_MAP, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import TankerkoenigDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -70,117 +62,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): - """Get the latest data from the API.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - logger: logging.Logger, - name: str, - update_interval: int, - ) -> None: - """Initialize the data object.""" - - super().__init__( - hass=hass, - logger=logger, - name=name, - update_interval=timedelta(minutes=update_interval), - ) - - self._api_key: str = entry.data[CONF_API_KEY] - self._selected_stations: list[str] = entry.data[CONF_STATIONS] - self.stations: dict[str, dict] = {} - self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES] - self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP] - - def setup(self) -> bool: - """Set up the tankerkoenig API.""" - for station_id in self._selected_stations: - try: - station_data = pytankerkoenig.getStationData(self._api_key, station_id) - except pytankerkoenig.customException as err: - if any(x in str(err).lower() for x in ("api-key", "apikey")): - raise ConfigEntryAuthFailed(err) from err - station_data = { - "ok": False, - "message": err, - "exception": True, - } - - if not station_data["ok"]: - _LOGGER.error( - "Error when adding station %s:\n %s", - station_id, - station_data["message"], - ) - continue - self.add_station(station_data["station"]) - if len(self.stations) > 10: - _LOGGER.warning( - "Found more than 10 stations to check. " - "This might invalidate your api-key on the long run. " - "Try using a smaller radius" - ) - return True - - async def _async_update_data(self) -> dict: - """Get the latest data from tankerkoenig.de.""" - _LOGGER.debug("Fetching new data from tankerkoenig.de") - station_ids = list(self.stations) - - prices = {} - - # The API seems to only return at most 10 results, so split the list in chunks of 10 - # and merge it together. - for index in range(ceil(len(station_ids) / 10)): - data = await self.hass.async_add_executor_job( - pytankerkoenig.getPriceList, - self._api_key, - station_ids[index * 10 : (index + 1) * 10], - ) - - _LOGGER.debug("Received data: %s", data) - if not data["ok"]: - raise UpdateFailed(data["message"]) - if "prices" not in data: - raise UpdateFailed( - "Did not receive price information from tankerkoenig.de" - ) - prices.update(data["prices"]) - return prices - - def add_station(self, station: dict): - """Add fuel station to the entity list.""" - station_id = station["id"] - if station_id in self.stations: - _LOGGER.warning( - "Sensor for station with id %s was already created", station_id - ) - return - - self.stations[station_id] = station - _LOGGER.debug("add_station called for station: %s", station) - - -class TankerkoenigCoordinatorEntity(CoordinatorEntity): - """Tankerkoenig base entity.""" - - _attr_has_entity_name = True - - def __init__( - self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict - ) -> None: - """Initialize the Tankerkoenig base entity.""" - super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - identifiers={(ATTR_ID, station["id"])}, - name=f"{station['brand']} {station['street']} {station['houseNumber']}", - model=station["brand"], - configuration_url="https://www.tankerkoenig.de", - entry_type=DeviceEntryType.SERVICE, - ) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index a6a79fd2d92..2cf8869fcae 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -12,8 +12,9 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator from .const import DOMAIN +from .coordinator import TankerkoenigDataUpdateCoordinator +from .entity import TankerkoenigCoordinatorEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py new file mode 100644 index 00000000000..536875f5733 --- /dev/null +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -0,0 +1,113 @@ +"""The Tankerkoenig update coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from math import ceil + +import pytankerkoenig + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_SHOW_ON_MAP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_FUEL_TYPES, CONF_STATIONS + +_LOGGER = logging.getLogger(__name__) + + +class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): + """Get the latest data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + logger: logging.Logger, + name: str, + update_interval: int, + ) -> None: + """Initialize the data object.""" + + super().__init__( + hass=hass, + logger=logger, + name=name, + update_interval=timedelta(minutes=update_interval), + ) + + self._api_key: str = entry.data[CONF_API_KEY] + self._selected_stations: list[str] = entry.data[CONF_STATIONS] + self.stations: dict[str, dict] = {} + self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES] + self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP] + + def setup(self) -> bool: + """Set up the tankerkoenig API.""" + for station_id in self._selected_stations: + try: + station_data = pytankerkoenig.getStationData(self._api_key, station_id) + except pytankerkoenig.customException as err: + if any(x in str(err).lower() for x in ("api-key", "apikey")): + raise ConfigEntryAuthFailed(err) from err + station_data = { + "ok": False, + "message": err, + "exception": True, + } + + if not station_data["ok"]: + _LOGGER.error( + "Error when adding station %s:\n %s", + station_id, + station_data["message"], + ) + continue + self.add_station(station_data["station"]) + if len(self.stations) > 10: + _LOGGER.warning( + "Found more than 10 stations to check. " + "This might invalidate your api-key on the long run. " + "Try using a smaller radius" + ) + return True + + async def _async_update_data(self) -> dict: + """Get the latest data from tankerkoenig.de.""" + _LOGGER.debug("Fetching new data from tankerkoenig.de") + station_ids = list(self.stations) + + prices = {} + + # The API seems to only return at most 10 results, so split the list in chunks of 10 + # and merge it together. + for index in range(ceil(len(station_ids) / 10)): + data = await self.hass.async_add_executor_job( + pytankerkoenig.getPriceList, + self._api_key, + station_ids[index * 10 : (index + 1) * 10], + ) + + _LOGGER.debug("Received data: %s", data) + if not data["ok"]: + raise UpdateFailed(data["message"]) + if "prices" not in data: + raise UpdateFailed( + "Did not receive price information from tankerkoenig.de" + ) + prices.update(data["prices"]) + return prices + + def add_station(self, station: dict): + """Add fuel station to the entity list.""" + station_id = station["id"] + if station_id in self.stations: + _LOGGER.warning( + "Sensor for station with id %s was already created", station_id + ) + return + + self.stations[station_id] = station + _LOGGER.debug("add_station called for station: %s", station) diff --git a/homeassistant/components/tankerkoenig/entity.py b/homeassistant/components/tankerkoenig/entity.py new file mode 100644 index 00000000000..6fbd9057679 --- /dev/null +++ b/homeassistant/components/tankerkoenig/entity.py @@ -0,0 +1,25 @@ +"""The tankerkoenig base entity.""" +from homeassistant.const import ATTR_ID +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import TankerkoenigDataUpdateCoordinator + + +class TankerkoenigCoordinatorEntity(CoordinatorEntity): + """Tankerkoenig base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict + ) -> None: + """Initialize the Tankerkoenig base entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(ATTR_ID, station["id"])}, + name=f"{station['brand']} {station['street']} {station['houseNumber']}", + model=station["brand"], + configuration_url="https://www.tankerkoenig.de", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index af21ac4b6d6..c309536cb9c 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -9,7 +9,6 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator from .const import ( ATTR_BRAND, ATTR_CITY, @@ -21,6 +20,8 @@ from .const import ( ATTRIBUTION, DOMAIN, ) +from .coordinator import TankerkoenigDataUpdateCoordinator +from .entity import TankerkoenigCoordinatorEntity _LOGGER = logging.getLogger(__name__) From 0da94c20b0ec219615443ff9dac4b9d5542fbe93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Aug 2023 13:47:01 -0400 Subject: [PATCH 026/984] Significantly reduce overhead to filter event triggers (#99376) * fast * cleanups * cleanups * cleanups * comment * comment * add more cover * comment * pull more examples from forums to validate cover --- .../homeassistant/triggers/event.py | 66 +++++++++---- .../homeassistant/triggers/test_event.py | 93 ++++++++++++++++++- 2 files changed, 138 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index d0e74d5b04e..a4266a70add 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -1,6 +1,7 @@ """Offer event listening automation rules.""" from __future__ import annotations +from collections.abc import ItemsView from typing import Any import voluptuous as vol @@ -47,9 +48,8 @@ async def async_attach_trigger( event_types = template.render_complex( config[CONF_EVENT_TYPE], variables, limited=True ) - removes = [] - - event_data_schema = None + event_data_schema: vol.Schema | None = None + event_data_items: ItemsView | None = None if CONF_EVENT_DATA in config: # Render the schema input template.attach(hass, config[CONF_EVENT_DATA]) @@ -57,13 +57,21 @@ async def async_attach_trigger( event_data.update( template.render_complex(config[CONF_EVENT_DATA], variables, limited=True) ) - # Build the schema - event_data_schema = vol.Schema( - {vol.Required(key): value for key, value in event_data.items()}, - extra=vol.ALLOW_EXTRA, - ) + # Build the schema or a an items view if the schema is simple + # and does not contain sub-dicts. We explicitly do not check for + # list like the context data below since lists are a special case + # only for context data. (see test test_event_data_with_list) + if any(isinstance(value, dict) for value in event_data.values()): + event_data_schema = vol.Schema( + {vol.Required(key): value for key, value in event_data.items()}, + extra=vol.ALLOW_EXTRA, + ) + else: + # Use a simple items comparison if possible + event_data_items = event_data.items() - event_context_schema = None + event_context_schema: vol.Schema | None = None + event_context_items: ItemsView | None = None if CONF_EVENT_CONTEXT in config: # Render the schema input template.attach(hass, config[CONF_EVENT_CONTEXT]) @@ -71,14 +79,23 @@ async def async_attach_trigger( event_context.update( template.render_complex(config[CONF_EVENT_CONTEXT], variables, limited=True) ) - # Build the schema - event_context_schema = vol.Schema( - { - vol.Required(key): _schema_value(value) - for key, value in event_context.items() - }, - extra=vol.ALLOW_EXTRA, - ) + # Build the schema or a an items view if the schema is simple + # and does not contain lists. Lists are a special case to support + # matching events by user_id. (see test test_if_fires_on_multiple_user_ids) + # This can likely be optimized further in the future to handle the + # multiple user_id case without requiring expensive schema + # validation. + if any(isinstance(value, list) for value in event_context.values()): + event_context_schema = vol.Schema( + { + vol.Required(key): _schema_value(value) + for key, value in event_context.items() + }, + extra=vol.ALLOW_EXTRA, + ) + else: + # Use a simple items comparison if possible + event_context_items = event_context.items() job = HassJob(action, f"event trigger {trigger_info}") @@ -88,9 +105,20 @@ async def async_attach_trigger( try: # Check that the event data and context match the configured # schema if one was provided - if event_data_schema: + if event_data_items: + # Fast path for simple items comparison + if not (event.data.items() >= event_data_items): + return False + elif event_data_schema: + # Slow path for schema validation event_data_schema(event.data) - if event_context_schema: + + if event_context_items: + # Fast path for simple items comparison + if not (event.context.as_dict().items() >= event_context_items): + return False + elif event_context_schema: + # Slow path for schema validation event_context_schema(dict(event.context.as_dict())) except vol.Invalid: # If event doesn't match, skip event diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 6fc7e5055ed..d996cd74da7 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -288,7 +288,11 @@ async def test_if_fires_on_event_with_empty_data_and_context_config( async def test_if_fires_on_event_with_nested_data(hass: HomeAssistant, calls) -> None: - """Test the firing of events with nested data.""" + """Test the firing of events with nested data. + + This test exercises the slow path of using vol.Schema to validate + matching event data. + """ assert await async_setup_component( hass, automation.DOMAIN, @@ -311,6 +315,87 @@ async def test_if_fires_on_event_with_nested_data(hass: HomeAssistant, calls) -> assert len(calls) == 1 +async def test_if_fires_on_event_with_empty_data(hass: HomeAssistant, calls) -> None: + """Test the firing of events with empty data. + + This test exercises the fast path to validate matching event data. + """ + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "event", + "event_type": "test_event", + "event_data": {}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + hass.bus.async_fire("test_event", {"any_attr": {}}) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_if_fires_on_sample_zha_event(hass: HomeAssistant, calls) -> None: + """Test the firing of events with a sample zha event. + + This test exercises the fast path to validate matching event data. + """ + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "event", + "event_type": "zha_event", + "event_data": { + "device_ieee": "00:15:8d:00:02:93:04:11", + "command": "attribute_updated", + "args": { + "attribute_id": 0, + "attribute_name": "on_off", + "value": True, + }, + }, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.bus.async_fire( + "zha_event", + { + "device_ieee": "00:15:8d:00:02:93:04:11", + "unique_id": "00:15:8d:00:02:93:04:11:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "command": "attribute_updated", + "args": {"attribute_id": 0, "attribute_name": "on_off", "value": True}, + }, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + hass.bus.async_fire( + "zha_event", + { + "device_ieee": "00:15:8d:00:02:93:04:11", + "unique_id": "00:15:8d:00:02:93:04:11:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "command": "attribute_updated", + "args": {"attribute_id": 0, "attribute_name": "on_off", "value": False}, + }, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_not_fires_if_event_data_not_matches( hass: HomeAssistant, calls ) -> None: @@ -362,7 +447,11 @@ async def test_if_not_fires_if_event_context_not_matches( async def test_if_fires_on_multiple_user_ids( hass: HomeAssistant, calls, context_with_user ) -> None: - """Test the firing of event when the trigger has multiple user ids.""" + """Test the firing of event when the trigger has multiple user ids. + + This test exercises the slow path of using vol.Schema to validate + matching event context. + """ assert await async_setup_component( hass, automation.DOMAIN, From d5adf33225a32861d6fec37f1a3e7391ff93edae Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 31 Aug 2023 20:35:11 +0200 Subject: [PATCH 027/984] Address late review for Nextcloud (#99226) --- .../components/nextcloud/__init__.py | 2 +- homeassistant/components/nextcloud/entity.py | 2 +- homeassistant/components/nextcloud/sensor.py | 196 ++++++++++-------- 3 files changed, 106 insertions(+), 94 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 27c9b8b6078..9cfe4aa7f70 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) for entity in entities: old_uid_start = f"{entry.data[CONF_URL]}#nextcloud_" - new_uid_start = f"{entry.data[CONF_URL]}#" + new_uid_start = f"{entry.entry_id}#" if entity.unique_id.startswith(old_uid_start): new_uid = entity.unique_id.replace(old_uid_start, new_uid_start) _LOGGER.debug("migrate unique id '%s' to '%s'", entity.unique_id, new_uid) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 92ba65a134b..b9dab9179c1 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -23,7 +23,7 @@ class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): ) -> None: """Initialize the Nextcloud sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.url}#{description.key}" + self._attr_unique_id = f"{entry.entry_id}#{description.key}" self._attr_device_info = DeviceInfo( configuration_url=coordinator.url, identifiers={(DOMAIN, entry.entry_id)}, diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 0cf30cee000..0133a9e7f76 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -1,8 +1,10 @@ """Summary data from Nextcoud.""" from __future__ import annotations -from datetime import UTC, datetime -from typing import Final, cast +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Final from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utc_from_timestamp from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -26,32 +28,42 @@ from .entity import NextcloudEntity UNIT_OF_LOAD: Final[str] = "load" -SENSORS: Final[list[SensorEntityDescription]] = [ - SensorEntityDescription( + +@dataclass +class NextcloudSensorEntityDescription(SensorEntityDescription): + """Describes Nextcloud sensor entity.""" + + value_fn: Callable[ + [str | int | float], str | int | float | datetime + ] = lambda value: value + + +SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ + NextcloudSensorEntityDescription( key="activeUsers_last1hour", translation_key="nextcloud_activeusers_last1hour", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="activeUsers_last24hours", translation_key="nextcloud_activeusers_last24hours", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="activeUsers_last5minutes", translation_key="nextcloud_activeusers_last5minutes", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:account-multiple", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_expunges", translation_key="nextcloud_cache_expunges", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_mem_size", translation_key="nextcloud_cache_mem_size", device_class=SensorDeviceClass.DATA_SIZE, @@ -60,56 +72,57 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_memory_type", translation_key="nextcloud_cache_memory_type", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_num_entries", translation_key="nextcloud_cache_num_entries", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_num_hits", translation_key="nextcloud_cache_num_hits", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_num_inserts", translation_key="nextcloud_cache_num_inserts", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_num_misses", translation_key="nextcloud_cache_num_misses", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_num_slots", translation_key="nextcloud_cache_num_slots", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_start_time", translation_key="nextcloud_cache_start_time", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + value_fn=lambda val: utc_from_timestamp(float(val)), ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="cache_ttl", translation_key="nextcloud_cache_ttl", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="database_size", translation_key="nextcloud_database_size", device_class=SensorDeviceClass.DATA_SIZE, @@ -118,19 +131,19 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="database_type", translation_key="nextcloud_database_type", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:database", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="database_version", translation_key="nextcloud_database_version", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:database", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="interned_strings_usage_buffer_size", translation_key="nextcloud_interned_strings_usage_buffer_size", device_class=SensorDeviceClass.DATA_SIZE, @@ -140,7 +153,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="interned_strings_usage_free_memory", translation_key="nextcloud_interned_strings_usage_free_memory", device_class=SensorDeviceClass.DATA_SIZE, @@ -150,13 +163,13 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="interned_strings_usage_number_of_strings", translation_key="nextcloud_interned_strings_usage_number_of_strings", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="interned_strings_usage_used_memory", translation_key="nextcloud_interned_strings_usage_used_memory", device_class=SensorDeviceClass.DATA_SIZE, @@ -166,7 +179,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="jit_buffer_free", translation_key="nextcloud_jit_buffer_free", device_class=SensorDeviceClass.DATA_SIZE, @@ -176,7 +189,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="jit_buffer_size", translation_key="nextcloud_jit_buffer_size", device_class=SensorDeviceClass.DATA_SIZE, @@ -186,93 +199,94 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="jit_kind", translation_key="nextcloud_jit_kind", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="jit_opt_flags", translation_key="nextcloud_jit_opt_flags", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="jit_opt_level", translation_key="nextcloud_jit_opt_level", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_blacklist_miss_ratio", translation_key="nextcloud_opcache_statistics_blacklist_miss_ratio", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_blacklist_misses", translation_key="nextcloud_opcache_statistics_blacklist_misses", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_hash_restarts", translation_key="nextcloud_opcache_statistics_hash_restarts", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_hits", translation_key="nextcloud_opcache_statistics_hits", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_last_restart_time", translation_key="nextcloud_opcache_statistics_last_restart_time", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + value_fn=lambda val: utc_from_timestamp(float(val)), ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_manual_restarts", translation_key="nextcloud_opcache_statistics_manual_restarts", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_max_cached_keys", translation_key="nextcloud_opcache_statistics_max_cached_keys", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_misses", translation_key="nextcloud_opcache_statistics_misses", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_num_cached_keys", translation_key="nextcloud_opcache_statistics_num_cached_keys", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_num_cached_scripts", translation_key="nextcloud_opcache_statistics_num_cached_scripts", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_oom_restarts", translation_key="nextcloud_opcache_statistics_oom_restarts", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_opcache_hit_rate", translation_key="nextcloud_opcache_statistics_opcache_hit_rate", entity_category=EntityCategory.DIAGNOSTIC, @@ -280,14 +294,15 @@ SENSORS: Final[list[SensorEntityDescription]] = [ native_unit_of_measurement=PERCENTAGE, suggested_display_precision=1, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="opcache_statistics_start_time", translation_key="nextcloud_opcache_statistics_start_time", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, + value_fn=lambda val: utc_from_timestamp(float(val)), ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_php_opcache_memory_usage_current_wasted_percentage", translation_key="nextcloud_server_php_opcache_memory_usage_current_wasted_percentage", entity_category=EntityCategory.DIAGNOSTIC, @@ -296,7 +311,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ native_unit_of_measurement=PERCENTAGE, suggested_display_precision=1, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_php_opcache_memory_usage_free_memory", translation_key="nextcloud_server_php_opcache_memory_usage_free_memory", device_class=SensorDeviceClass.DATA_SIZE, @@ -307,7 +322,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_php_opcache_memory_usage_used_memory", translation_key="nextcloud_server_php_opcache_memory_usage_used_memory", device_class=SensorDeviceClass.DATA_SIZE, @@ -318,7 +333,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_php_opcache_memory_usage_wasted_memory", translation_key="nextcloud_server_php_opcache_memory_usage_wasted_memory", device_class=SensorDeviceClass.DATA_SIZE, @@ -329,7 +344,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_php_max_execution_time", translation_key="nextcloud_server_php_max_execution_time", device_class=SensorDeviceClass.DURATION, @@ -337,7 +352,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ icon="mdi:language-php", native_unit_of_measurement=UnitOfTime.SECONDS, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_php_memory_limit", translation_key="nextcloud_server_php_memory_limit", device_class=SensorDeviceClass.DATA_SIZE, @@ -347,7 +362,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_php_upload_max_filesize", translation_key="nextcloud_server_php_upload_max_filesize", device_class=SensorDeviceClass.DATA_SIZE, @@ -357,62 +372,62 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_php_version", translation_key="nextcloud_server_php_version", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:language-php", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="server_webserver", translation_key="nextcloud_server_webserver", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="shares_num_fed_shares_sent", translation_key="nextcloud_shares_num_fed_shares_sent", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="shares_num_fed_shares_received", translation_key="nextcloud_shares_num_fed_shares_received", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="shares_num_shares", translation_key="nextcloud_shares_num_shares", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="shares_num_shares_groups", translation_key="nextcloud_shares_num_shares_groups", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="shares_num_shares_link", translation_key="nextcloud_shares_num_shares_link", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="shares_num_shares_link_no_password", translation_key="nextcloud_shares_num_shares_link_no_password", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="shares_num_shares_mail", translation_key="nextcloud_shares_num_shares_mail", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="shares_num_shares_room", translation_key="nextcloud_shares_num_shares_room", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( - key="server_num_shares_user", + NextcloudSensorEntityDescription( + key="shares_num_shares_user", translation_key="nextcloud_shares_num_shares_user", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="sma_avail_mem", translation_key="nextcloud_sma_avail_mem", device_class=SensorDeviceClass.DATA_SIZE, @@ -422,13 +437,13 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="sma_num_seg", translation_key="nextcloud_sma_num_seg", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="sma_seg_size", translation_key="nextcloud_sma_seg_size", device_class=SensorDeviceClass.DATA_SIZE, @@ -438,64 +453,64 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=1, suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="storage_num_files", translation_key="nextcloud_storage_num_files", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="storage_num_storages", translation_key="nextcloud_storage_num_storages", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="storage_num_storages_home", translation_key="nextcloud_storage_num_storages_home", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="storage_num_storages_local", translation_key="nextcloud_storage_num_storages_local", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="storage_num_storages_other", translation_key="nextcloud_storage_num_storages_other", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="storage_num_users", translation_key="nextcloud_storage_num_users", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_apps_num_installed", translation_key="nextcloud_system_apps_num_installed", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_apps_num_updates_available", translation_key="nextcloud_system_apps_num_updates_available", icon="mdi:update", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_cpuload_1", translation_key="nextcloud_system_cpuload_1", native_unit_of_measurement=UNIT_OF_LOAD, icon="mdi:chip", suggested_display_precision=2, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_cpuload_5", translation_key="nextcloud_system_cpuload_5", native_unit_of_measurement=UNIT_OF_LOAD, icon="mdi:chip", suggested_display_precision=2, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_cpuload_15", translation_key="nextcloud_system_cpuload_15", native_unit_of_measurement=UNIT_OF_LOAD, icon="mdi:chip", suggested_display_precision=2, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_freespace", translation_key="nextcloud_system_freespace", device_class=SensorDeviceClass.DATA_SIZE, @@ -504,7 +519,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=2, suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_mem_free", translation_key="nextcloud_system_mem_free", device_class=SensorDeviceClass.DATA_SIZE, @@ -513,7 +528,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=2, suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_mem_total", translation_key="nextcloud_system_mem_total", device_class=SensorDeviceClass.DATA_SIZE, @@ -522,25 +537,25 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=2, suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_memcache.distributed", translation_key="nextcloud_system_memcache_distributed", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_memcache.local", translation_key="nextcloud_system_memcache_local", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_memcache.locking", translation_key="nextcloud_system_memcache_locking", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_swap_total", translation_key="nextcloud_system_swap_total", device_class=SensorDeviceClass.DATA_SIZE, @@ -549,7 +564,7 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=2, suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_swap_free", translation_key="nextcloud_system_swap_free", device_class=SensorDeviceClass.DATA_SIZE, @@ -558,11 +573,11 @@ SENSORS: Final[list[SensorEntityDescription]] = [ suggested_display_precision=2, suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_theme", translation_key="nextcloud_system_theme", ), - SensorEntityDescription( + NextcloudSensorEntityDescription( key="system_version", translation_key="nextcloud_system_version", ), @@ -586,13 +601,10 @@ async def async_setup_entry( class NextcloudSensor(NextcloudEntity, SensorEntity): """Represents a Nextcloud sensor.""" + entity_description: NextcloudSensorEntityDescription + @property - def native_value(self) -> StateType | datetime: + def native_value(self) -> str | int | float | datetime: """Return the state for this sensor.""" val = self.coordinator.data.get(self.entity_description.key) - if ( - getattr(self.entity_description, "device_class", None) - == SensorDeviceClass.TIMESTAMP - ): - return datetime.fromtimestamp(cast(int, val), tz=UTC) - return val + return self.entity_description.value_fn(val) # type: ignore[arg-type] From 97d38f4ca52db114afcaa3ae53e5b3fe5e95cce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 31 Aug 2023 21:59:01 +0200 Subject: [PATCH 028/984] Update AEMET-OpenData to v0.4.4 (#99418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/aemet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index c43e7a0b402..1c65572a64e 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.4.3"] + "requirements": ["AEMET-OpenData==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index fd5b63a84cc..87184ecf550 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2,7 +2,7 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.3 +AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.57 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index efe3489d1e4..4219f3a695f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.3 +AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.57 From 5e03954e6940827a04b407980adce1bcfad922b6 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Thu, 31 Aug 2023 16:50:25 -0500 Subject: [PATCH 029/984] Add @kbx81 as esphome codeowner (#99427) * Add @kbx81 as esphome codeowner * Add @kbx81 as esphome codeowner, take 2 --- CODEOWNERS | 4 ++-- homeassistant/components/esphome/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 2d28671fce5..65a36205518 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -354,8 +354,8 @@ build.json @home-assistant/supervisor /homeassistant/components/eq3btsmart/ @rytilahti /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila -/homeassistant/components/esphome/ @OttoWinter @jesserockz @bdraco -/tests/components/esphome/ @OttoWinter @jesserockz @bdraco +/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco +/tests/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 /homeassistant/components/event/ @home-assistant/core diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d0ab27656c2..5a4220464e7 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -2,7 +2,7 @@ "domain": "esphome", "name": "ESPHome", "after_dependencies": ["zeroconf", "tag"], - "codeowners": ["@OttoWinter", "@jesserockz", "@bdraco"], + "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth"], "dhcp": [ From 2dab9eaf86e8a23cf0afa1f290f7d6bd0a6bafb8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Sep 2023 02:58:40 +0200 Subject: [PATCH 030/984] Use shorthand attributes in Isy994 (#99395) --- .../components/isy994/binary_sensor.py | 7 ++--- homeassistant/components/isy994/climate.py | 27 ++----------------- homeassistant/components/isy994/switch.py | 7 ++--- 3 files changed, 6 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 69db4afd1be..aa7c3d55147 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -421,6 +421,8 @@ class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity): class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity): """Representation of the battery state of an ISY sensor.""" + _attr_device_class = BinarySensorDeviceClass.BATTERY + def __init__( self, node: Node, @@ -522,11 +524,6 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity) """ return bool(self._computed_state) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Get the class of this device.""" - return BinarySensorDeviceClass.BATTERY - @property def extra_state_attributes(self) -> dict[str, Any]: """Get the state attributes for the device.""" diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 4ddbbd86060..3ac2fd18473 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -83,6 +83,8 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + _attr_target_temperature_step = 1.0 + _attr_fan_modes = [FAN_AUTO, FAN_ON] def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the ISY Thermostat entity.""" @@ -90,13 +92,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): self._uom = self._node.uom if isinstance(self._uom, list): self._uom = self._node.uom[0] - self._hvac_action: str | None = None - self._hvac_mode: str | None = None - self._fan_mode: str | None = None - self._temp_unit = None - self._current_humidity = 0 - self._target_temp_low = 0 - self._target_temp_high = 0 @property def temperature_unit(self) -> str: @@ -155,11 +150,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): self._node.status, self._uom, self._node.prec, 1 ) - @property - def target_temperature_step(self) -> float | None: - """Return the supported step of target temperature.""" - return 1.0 - @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" @@ -185,11 +175,6 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): return None return convert_isy_value_to_hass(target.value, target.uom, target.prec, 1) - @property - def fan_modes(self) -> list[str]: - """Return the list of available fan modes.""" - return [FAN_AUTO, FAN_ON] - @property def fan_mode(self) -> str: """Return the current fan mode ie. auto, on.""" @@ -210,26 +195,18 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): target_temp_low = target_temp if target_temp_low is not None: await self._node.set_climate_setpoint_heat(int(target_temp_low)) - # Presumptive setting--event stream will correct if cmd fails: - self._target_temp_low = target_temp_low if target_temp_high is not None: await self._node.set_climate_setpoint_cool(int(target_temp_high)) - # Presumptive setting--event stream will correct if cmd fails: - self._target_temp_high = target_temp_high self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" _LOGGER.debug("Requested fan mode %s", fan_mode) await self._node.set_fan_mode(HA_FAN_TO_ISY.get(fan_mode)) - # Presumptive setting--event stream will correct if cmd fails: - self._fan_mode = fan_mode self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" _LOGGER.debug("Requested operation mode %s", hvac_mode) await self._node.set_climate_mode(HA_HVAC_TO_ISY.get(hvac_mode)) - # Presumptive setting--event stream will correct if cmd fails: - self._hvac_mode = hvac_mode self.async_write_ha_state() diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 39b84faad30..8467cba9e6a 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -112,6 +112,8 @@ class ISYSwitchEntity(ISYNodeEntity, SwitchEntity): class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity): """A representation of an ISY program switch.""" + _attr_icon = "mdi:script-text-outline" # Matches isy program icon + @property def is_on(self) -> bool: """Get whether the ISY switch program is on.""" @@ -131,11 +133,6 @@ class ISYSwitchProgramEntity(ISYProgramEntity, SwitchEntity): f"Unable to run 'else' clause on program switch {self._actions.address}" ) - @property - def icon(self) -> str: - """Get the icon for programs.""" - return "mdi:script-text-outline" # Matches isy program icon - class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): """A representation of an ISY enable/disable switch.""" From 1539853c0a539c57aee09b04bd03fb75ea724dc9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 31 Aug 2023 20:33:36 -0700 Subject: [PATCH 031/984] Update google-nest-sdm to 3.0.2 (#99175) * Update google-nest-sdm to 3.0.2 * Fix device typing * Update homeassistant/components/nest/device_info.py Co-authored-by: jan iversen --------- Co-authored-by: jan iversen --- homeassistant/components/nest/device_info.py | 2 +- homeassistant/components/nest/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nest/test_diagnostics.py | 4 ++-- tests/components/nest/test_media_source.py | 4 +++- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 1bdb60ee1b4..35e32ccf1bc 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -69,7 +69,7 @@ class NestDeviceInfo: # The API intentionally returns minimal information about specific # devices, instead relying on traits, but we can infer a generic model # name based on the type - return DEVICE_TYPE_MAP.get(self._device.type) + return DEVICE_TYPE_MAP.get(self._device.type or "", None) @property def suggested_area(self) -> str | None: diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 54bc44a09b3..bf24fc4a4e9 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -18,7 +18,7 @@ ], "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", - "loggers": ["google_nest_sdm", "nest"], + "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==2.2.5"] + "requirements": ["google-nest-sdm==3.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 87184ecf550..f4b43add13f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -897,7 +897,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==2.2.5 +google-nest-sdm==3.0.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4219f3a695f..80e1f1efb4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -707,7 +707,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==2.2.5 +google-nest-sdm==3.0.2 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py index 191253a2a9a..c9b5f2f0de1 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -60,7 +60,7 @@ CAMERA_API_DATA = { "type": "sdm.devices.types.CAMERA", "traits": { "sdm.devices.traits.CameraLiveStream": { - "videoCodecs": "H264", + "videoCodecs": ["H264"], "supportedProtocols": ["RTSP"], }, }, @@ -71,7 +71,7 @@ CAMERA_DIAGNOSTIC_DATA = { "name": "**REDACTED**", "traits": { "sdm.devices.traits.CameraLiveStream": { - "videoCodecs": "H264", + "videoCodecs": ["H264"], "supportedProtocols": ["RTSP"], }, }, diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 6c827e76163..a1c62799585 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -231,7 +231,9 @@ def create_battery_event_data( ( "sdm.devices.types.THERMOSTAT", { - "sdm.devices.traits.Temperature": {}, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 22.0, + }, }, ) ], From dc4ed5fea9faad4c6eabeb5c56917e3c5f5b6170 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Fri, 1 Sep 2023 02:24:13 -0400 Subject: [PATCH 032/984] Update asynsleepiq library to 1.3.7 (#99431) --- homeassistant/components/sleepiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 3d757e2328d..874ae90ec4a 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.3.5"] + "requirements": ["asyncsleepiq==1.3.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index f4b43add13f..c63c6e93ca9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -464,7 +464,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.3.5 +asyncsleepiq==1.3.7 # homeassistant.components.aten_pe # atenpdu==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80e1f1efb4d..1693b493962 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -409,7 +409,7 @@ async-upnp-client==0.35.0 async_interrupt==1.1.1 # homeassistant.components.sleepiq -asyncsleepiq==1.3.5 +asyncsleepiq==1.3.7 # homeassistant.components.aurora auroranoaa==0.0.3 From bbc390837e2ce3c73d68d75680ec4fea3571b2c9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 1 Sep 2023 08:29:07 +0200 Subject: [PATCH 033/984] Move airnow coordinator to its own file (#99423) --- .coveragerc | 1 + homeassistant/components/airnow/__init__.py | 95 +----------------- .../components/airnow/coordinator.py | 99 +++++++++++++++++++ 3 files changed, 102 insertions(+), 93 deletions(-) create mode 100644 homeassistant/components/airnow/coordinator.py diff --git a/.coveragerc b/.coveragerc index 12659c47092..bf3dd5f4a00 100644 --- a/.coveragerc +++ b/.coveragerc @@ -34,6 +34,7 @@ omit = homeassistant/components/agent_dvr/camera.py homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py + homeassistant/components/airnow/coordinator.py homeassistant/components/airnow/sensor.py homeassistant/components/airq/__init__.py homeassistant/components/airq/coordinator.py diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index c4d52c6ac8e..8fe2291d3b3 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -2,11 +2,6 @@ import datetime import logging -from aiohttp.client_exceptions import ClientConnectorError -from pyairnow import WebServiceAPI -from pyairnow.conv import aqi_to_concentration -from pyairnow.errors import AirNowError - from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, @@ -17,26 +12,9 @@ from homeassistant.const import ( ) 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 ( - ATTR_API_AQI, - ATTR_API_AQI_DESCRIPTION, - ATTR_API_AQI_LEVEL, - ATTR_API_AQI_PARAM, - ATTR_API_CAT_DESCRIPTION, - ATTR_API_CAT_LEVEL, - ATTR_API_CATEGORY, - ATTR_API_PM25, - ATTR_API_POLLUTANT, - ATTR_API_REPORT_DATE, - ATTR_API_REPORT_HOUR, - ATTR_API_STATE, - ATTR_API_STATION, - ATTR_API_STATION_LATITUDE, - ATTR_API_STATION_LONGITUDE, - DOMAIN, -) +from .const import DOMAIN +from .coordinator import AirNowDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] @@ -107,72 +85,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class AirNowDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to hold Airly data.""" - - def __init__( - self, hass, session, api_key, latitude, longitude, distance, update_interval - ): - """Initialize.""" - self.latitude = latitude - self.longitude = longitude - self.distance = distance - - self.airnow = WebServiceAPI(api_key, session=session) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self): - """Update data via library.""" - data = {} - try: - obs = await self.airnow.observations.latLong( - self.latitude, - self.longitude, - distance=self.distance, - ) - - except (AirNowError, ClientConnectorError) as error: - raise UpdateFailed(error) from error - - if not obs: - raise UpdateFailed("No data was returned from AirNow") - - max_aqi = 0 - max_aqi_level = 0 - max_aqi_desc = "" - max_aqi_poll = "" - for obv in obs: - # Convert AQIs to Concentration - pollutant = obv[ATTR_API_AQI_PARAM] - concentration = aqi_to_concentration(obv[ATTR_API_AQI], pollutant) - data[obv[ATTR_API_AQI_PARAM]] = concentration - - # Overall AQI is the max of all pollutant AQIs - if obv[ATTR_API_AQI] > max_aqi: - max_aqi = obv[ATTR_API_AQI] - max_aqi_level = obv[ATTR_API_CATEGORY][ATTR_API_CAT_LEVEL] - max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION] - max_aqi_poll = pollutant - - # Copy other data from PM2.5 Value - if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25: - # Copy Report Details - data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] - data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] - - # Copy Station Details - data[ATTR_API_STATE] = obv[ATTR_API_STATE] - data[ATTR_API_STATION] = obv[ATTR_API_STATION] - data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE] - data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE] - - # Store Overall AQI - data[ATTR_API_AQI] = max_aqi - data[ATTR_API_AQI_LEVEL] = max_aqi_level - data[ATTR_API_AQI_DESCRIPTION] = max_aqi_desc - data[ATTR_API_POLLUTANT] = max_aqi_poll - - return data diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py new file mode 100644 index 00000000000..7a4ad46cd82 --- /dev/null +++ b/homeassistant/components/airnow/coordinator.py @@ -0,0 +1,99 @@ +"""DataUpdateCoordinator for the AirNow integration.""" +import logging + +from aiohttp.client_exceptions import ClientConnectorError +from pyairnow import WebServiceAPI +from pyairnow.conv import aqi_to_concentration +from pyairnow.errors import AirNowError + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_API_AQI, + ATTR_API_AQI_DESCRIPTION, + ATTR_API_AQI_LEVEL, + ATTR_API_AQI_PARAM, + ATTR_API_CAT_DESCRIPTION, + ATTR_API_CAT_LEVEL, + ATTR_API_CATEGORY, + ATTR_API_PM25, + ATTR_API_POLLUTANT, + ATTR_API_REPORT_DATE, + ATTR_API_REPORT_HOUR, + ATTR_API_STATE, + ATTR_API_STATION, + ATTR_API_STATION_LATITUDE, + ATTR_API_STATION_LONGITUDE, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class AirNowDataUpdateCoordinator(DataUpdateCoordinator): + """The AirNow update coordinator.""" + + def __init__( + self, hass, session, api_key, latitude, longitude, distance, update_interval + ): + """Initialize.""" + self.latitude = latitude + self.longitude = longitude + self.distance = distance + + self.airnow = WebServiceAPI(api_key, session=session) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + """Update data via library.""" + data = {} + try: + obs = await self.airnow.observations.latLong( + self.latitude, + self.longitude, + distance=self.distance, + ) + + except (AirNowError, ClientConnectorError) as error: + raise UpdateFailed(error) from error + + if not obs: + raise UpdateFailed("No data was returned from AirNow") + + max_aqi = 0 + max_aqi_level = 0 + max_aqi_desc = "" + max_aqi_poll = "" + for obv in obs: + # Convert AQIs to Concentration + pollutant = obv[ATTR_API_AQI_PARAM] + concentration = aqi_to_concentration(obv[ATTR_API_AQI], pollutant) + data[obv[ATTR_API_AQI_PARAM]] = concentration + + # Overall AQI is the max of all pollutant AQIs + if obv[ATTR_API_AQI] > max_aqi: + max_aqi = obv[ATTR_API_AQI] + max_aqi_level = obv[ATTR_API_CATEGORY][ATTR_API_CAT_LEVEL] + max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION] + max_aqi_poll = pollutant + + # Copy other data from PM2.5 Value + if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25: + # Copy Report Details + data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] + data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] + + # Copy Station Details + data[ATTR_API_STATE] = obv[ATTR_API_STATE] + data[ATTR_API_STATION] = obv[ATTR_API_STATION] + data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE] + data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE] + + # Store Overall AQI + data[ATTR_API_AQI] = max_aqi + data[ATTR_API_AQI_LEVEL] = max_aqi_level + data[ATTR_API_AQI_DESCRIPTION] = max_aqi_desc + data[ATTR_API_POLLUTANT] = max_aqi_poll + + return data From 6c93865ceec0f5e54538069a3e23f6d01f25236b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Sep 2023 10:13:34 +0200 Subject: [PATCH 034/984] Use shorthand attributes in Insteon (#99392) --- .../components/insteon/binary_sensor.py | 7 +------ homeassistant/components/insteon/climate.py | 18 +++--------------- homeassistant/components/insteon/fan.py | 6 +----- 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index f895b9c7f6a..02af89dba01 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -76,12 +76,7 @@ class InsteonBinarySensorEntity(InsteonEntity, BinarySensorEntity): def __init__(self, device, group): """Initialize the INSTEON binary sensor.""" super().__init__(device, group) - self._sensor_type = SENSOR_TYPES.get(self._insteon_device_group.name) - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._sensor_type + self._attr_device_class = SENSOR_TYPES.get(self._insteon_device_group.name) @property def is_on(self): diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 48ff898d6aa..74fb11491c0 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -88,6 +88,9 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + _attr_hvac_modes = list(HVAC_MODES.values()) + _attr_fan_modes = list(FAN_MODES.values()) + _attr_min_humidity = 1 @property def temperature_unit(self) -> str: @@ -106,11 +109,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): """Return hvac operation ie. heat, cool mode.""" return HVAC_MODES[self._insteon_device.groups[SYSTEM_MODE].value] - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes.""" - return list(HVAC_MODES.values()) - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -144,11 +142,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): """Return the fan setting.""" return FAN_MODES[self._insteon_device.groups[FAN_MODE].value] - @property - def fan_modes(self) -> list[str] | None: - """Return the list of available fan modes.""" - return list(FAN_MODES.values()) - @property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" @@ -157,11 +150,6 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): # May not be loaded yet so return a default if required return (high + low) / 2 if high and low else None - @property - def min_humidity(self) -> int: - """Return the minimum humidity.""" - return 1 - @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported. diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 92f56098a91..da9e3de6422 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -50,6 +50,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity): """An INSTEON fan entity.""" _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_speed_count = 3 @property def percentage(self) -> int | None: @@ -58,11 +59,6 @@ class InsteonFanEntity(InsteonEntity, FanEntity): return None return ranged_value_to_percentage(SPEED_RANGE, self._insteon_device_group.value) - @property - def speed_count(self) -> int: - """Flag supported features.""" - return 3 - async def async_turn_on( self, percentage: int | None = None, From 65246b99ec1a5dae101ee54ee5202f7aecb93b6d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Sep 2023 10:34:09 +0200 Subject: [PATCH 035/984] Use shorthand attributes in iZone (#99397) --- homeassistant/components/izone/climate.py | 40 +++++------------------ 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 2dcdd72f6b9..1ff016c3177 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -135,6 +135,7 @@ class ControllerDevice(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None + _attr_target_temperature_step = 0.5 def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" @@ -165,13 +166,13 @@ class ControllerDevice(ClimateEntity): self._fan_to_pizone = {} for fan in controller.fan_modes: self._fan_to_pizone[_IZONE_FAN_TO_HA[fan]] = fan - self._available = True + self._attr_unique_id = controller.device_uid self._attr_device_info = DeviceInfo( - identifiers={(IZONE, self.unique_id)}, + identifiers={(IZONE, controller.device_uid)}, manufacturer="IZone", - model=self._controller.sys_type, - name=f"iZone Controller {self._controller.device_uid}", + model=controller.sys_type, + name=f"iZone Controller {controller.device_uid}", ) # Create the zones @@ -224,11 +225,6 @@ class ControllerDevice(ClimateEntity): ) ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - @callback def set_available(self, available: bool, ex: Exception | None = None) -> None: """Set availability for the controller. @@ -247,17 +243,12 @@ class ControllerDevice(ClimateEntity): ex, ) - self._available = available + self._attr_available = available self.async_write_ha_state() for zone in self.zones.values(): if zone.hass is not None: zone.async_schedule_update_ha_state() - @property - def unique_id(self) -> str: - """Return the ID of the controller device.""" - return self._controller.device_uid - @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" @@ -364,11 +355,6 @@ class ControllerDevice(ClimateEntity): """Return the current supply, or in duct, temperature.""" return self._controller.temp_supply - @property - def target_temperature_step(self) -> float | None: - """Return the supported step of target temperature.""" - return 0.5 - @property def fan_mode(self) -> str | None: """Return the fan setting.""" @@ -444,6 +430,7 @@ class ZoneDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 0.5 def __init__(self, controller: ControllerDevice, zone: Zone) -> None: """Initialise ZoneDevice.""" @@ -462,7 +449,8 @@ class ZoneDevice(ClimateEntity): HVACMode.HEAT_COOL: Zone.Mode.AUTO, } self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - + self._attr_unique_id = f"{controller.unique_id}_z{zone.index + 1}" + assert controller.unique_id self._attr_device_info = DeviceInfo( identifiers={ (IZONE, controller.unique_id, zone.index) # type:ignore[arg-type] @@ -509,11 +497,6 @@ class ZoneDevice(ClimateEntity): """Return True if entity is available.""" return self._controller.available - @property - def unique_id(self) -> str: - """Return the ID of the controller device.""" - return f"{self._controller.unique_id}_z{self._zone.index + 1}" - @property @_return_on_connection_error(0) def supported_features(self) -> ClimateEntityFeature: @@ -548,11 +531,6 @@ class ZoneDevice(ClimateEntity): return None return self._zone.temp_setpoint - @property - def target_temperature_step(self) -> float: - """Return the supported step of target temperature.""" - return 0.5 - @property def min_temp(self) -> float: """Return the minimum temperature.""" From 680775c3e06512817fbf1a301eb39142a83e8f7f Mon Sep 17 00:00:00 2001 From: rappenze Date: Fri, 1 Sep 2023 10:48:08 +0200 Subject: [PATCH 036/984] Discover more power and energy sensors in fibaro integration (#98253) --- homeassistant/components/fibaro/sensor.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index c41c4afe312..b98e12b889e 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -113,7 +113,15 @@ async def async_setup_entry( # main sensors are created even if the entity type is not known entities.append(FibaroSensor(device, entity_description)) - for platform in (Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH): + for platform in ( + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.LIGHT, + Platform.LOCK, + Platform.SENSOR, + Platform.SWITCH, + ): for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][platform]: for entity_description in ADDITIONAL_SENSOR_TYPES: if entity_description.key in device.properties: From 38270ee823c6b1fe3b34d9d0eb5a51b57e0f98b9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 1 Sep 2023 09:05:45 -0400 Subject: [PATCH 037/984] Create a ZHA repair when directly accessing a radio with multi-PAN firmware (#98275) * Add the SiLabs flasher as a dependency * Create a repair if the wrong firmware is detected on an EZSP device * Update homeassistant/components/zha/strings.json Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> * Provide the ZHA config entry as a reusable fixture * Create a separate repair when using non-Nabu Casa hardware * Add unit tests * Drop extraneous `config_entry.add_to_hass` added in 021def44 * Fully unit test all edge cases * Move `socket://`-ignoring logic into repair function * Open a repair from ZHA flows when the wrong firmware is running * Fix existing unit tests * Link to the flashing section in the documentation * Reduce repair severity to `ERROR` * Make issue persistent * Add unit tests for new radio probing states * Add unit tests for new config flow steps * Handle probing failure raising an exception * Implement review suggestions * Address review comments --------- Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- homeassistant/components/zha/__init__.py | 21 +- homeassistant/components/zha/config_flow.py | 26 +- homeassistant/components/zha/manifest.json | 6 +- homeassistant/components/zha/radio_manager.py | 23 +- homeassistant/components/zha/repairs.py | 126 ++++++++++ homeassistant/components/zha/strings.json | 16 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/hassfest/dependencies.py | 1 + tests/components/zha/conftest.py | 33 ++- tests/components/zha/test_config_flow.py | 75 ++++-- tests/components/zha/test_diagnostics.py | 6 +- tests/components/zha/test_radio_manager.py | 63 ++++- tests/components/zha/test_repairs.py | 235 ++++++++++++++++++ 14 files changed, 587 insertions(+), 50 deletions(-) create mode 100644 homeassistant/components/zha/repairs.py create mode 100644 tests/components/zha/test_repairs.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e48f8ce2096..1c4c3e776d0 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -12,13 +12,14 @@ from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType -from . import websocket_api +from . import repairs, websocket_api from .core import ZHAGateway from .core.const import ( BAUD_RATES, @@ -134,7 +135,23 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("ZHA storage file does not exist or was already removed") zha_gateway = ZHAGateway(hass, config, config_entry) - await zha_gateway.async_initialize() + + try: + await zha_gateway.async_initialize() + except Exception: # pylint: disable=broad-except + if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: + try: + await repairs.warn_on_wrong_silabs_firmware( + hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + ) + except repairs.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 + + raise + + repairs.async_delete_blocking_issues(hass) config_entry.async_on_unload(zha_gateway.shutdown) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 6ac3a155ed9..1b6bbee5159 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -35,6 +35,7 @@ from .core.const import ( from .radio_manager import ( HARDWARE_DISCOVERY_SCHEMA, RECOMMENDED_RADIOS, + ProbeResult, ZhaRadioManager, ) @@ -60,6 +61,8 @@ OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure" UPLOADED_BACKUP_FILE = "uploaded_backup_file" +REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/" + DEFAULT_ZHA_ZEROCONF_PORT = 6638 ESPHOME_API_PORT = 6053 @@ -187,7 +190,13 @@ class BaseZhaFlow(FlowHandler): port = ports[list_of_ports.index(user_selection)] self._radio_mgr.device_path = port.device - if not await self._radio_mgr.detect_radio_type(): + probe_result = await self._radio_mgr.detect_radio_type() + if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: + return self.async_abort( + reason="wrong_firmware_installed", + description_placeholders={"repair_url": REPAIR_MY_URL}, + ) + if probe_result == ProbeResult.PROBING_FAILED: # Did not autodetect anything, proceed to manual selection return await self.async_step_manual_pick_radio_type() @@ -530,10 +539,17 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN # config flow logic that interacts with hardware. if user_input is not None or not onboarding.async_is_onboarded(self.hass): # Probe the radio type if we don't have one yet - if ( - self._radio_mgr.radio_type is None - and not await self._radio_mgr.detect_radio_type() - ): + if self._radio_mgr.radio_type is None: + probe_result = await self._radio_mgr.detect_radio_type() + else: + probe_result = ProbeResult.RADIO_TYPE_DETECTED + + if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: + return self.async_abort( + reason="wrong_firmware_installed", + description_placeholders={"repair_url": REPAIR_MY_URL}, + ) + if probe_result == ProbeResult.PROBING_FAILED: # This path probably will not happen now that we have # more precise USB matching unless there is a problem # with the device diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index cd0dc2db5ae..809b576defa 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -17,7 +17,8 @@ "zigpy_deconz", "zigpy_xbee", "zigpy_zigate", - "zigpy_znp" + "zigpy_znp", + "universal_silabs_flasher" ], "requirements": [ "bellows==0.36.1", @@ -28,7 +29,8 @@ "zigpy==0.57.0", "zigpy-xbee==0.18.1", "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.4" + "zigpy-znp==0.11.4", + "universal-silabs-flasher==0.0.13" ], "usb": [ { diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 4e70fc2247f..751fea99847 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -5,6 +5,7 @@ import asyncio import contextlib from contextlib import suppress import copy +import enum import logging import os from typing import Any @@ -20,6 +21,7 @@ from homeassistant import config_entries from homeassistant.components import usb from homeassistant.core import HomeAssistant +from . import repairs from .core.const import ( CONF_DATABASE, CONF_RADIO_TYPE, @@ -76,6 +78,14 @@ HARDWARE_MIGRATION_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) +class ProbeResult(enum.StrEnum): + """Radio firmware probing result.""" + + RADIO_TYPE_DETECTED = "radio_type_detected" + WRONG_FIRMWARE_INSTALLED = "wrong_firmware_installed" + PROBING_FAILED = "probing_failed" + + def _allow_overwrite_ezsp_ieee( backup: zigpy.backups.NetworkBackup, ) -> zigpy.backups.NetworkBackup: @@ -171,8 +181,10 @@ class ZhaRadioManager: return RadioType[radio_type] - async def detect_radio_type(self) -> bool: + async def detect_radio_type(self) -> ProbeResult: """Probe all radio types on the current port.""" + assert self.device_path is not None + for radio in AUTOPROBE_RADIOS: _LOGGER.debug("Attempting to probe radio type %s", radio) @@ -191,9 +203,14 @@ class ZhaRadioManager: self.radio_type = radio self.device_settings = dev_config - return True + repairs.async_delete_blocking_issues(self.hass) + return ProbeResult.RADIO_TYPE_DETECTED - return False + with suppress(repairs.AlreadyRunningEZSP): + if await repairs.warn_on_wrong_silabs_firmware(self.hass, self.device_path): + return ProbeResult.WRONG_FIRMWARE_INSTALLED + + return ProbeResult.PROBING_FAILED async def async_load_network_settings( self, *, create_backup: bool = False diff --git a/homeassistant/components/zha/repairs.py b/homeassistant/components/zha/repairs.py new file mode 100644 index 00000000000..ac523f37aa0 --- /dev/null +++ b/homeassistant/components/zha/repairs.py @@ -0,0 +1,126 @@ +"""ZHA repairs for common environmental and device problems.""" +from __future__ import annotations + +import enum +import logging + +from universal_silabs_flasher.const import ApplicationType +from universal_silabs_flasher.flasher import Flasher + +from homeassistant.components.homeassistant_sky_connect import ( + hardware as skyconnect_hardware, +) +from homeassistant.components.homeassistant_yellow import ( + RADIO_DEVICE as YELLOW_RADIO_DEVICE, + hardware as yellow_hardware, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir + +from .core.const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AlreadyRunningEZSP(Exception): + """The device is already running EZSP firmware.""" + + +class HardwareType(enum.StrEnum): + """Detected Zigbee hardware type.""" + + SKYCONNECT = "skyconnect" + YELLOW = "yellow" + OTHER = "other" + + +DISABLE_MULTIPAN_URL = { + HardwareType.YELLOW: ( + "https://yellow.home-assistant.io/guides/disable-multiprotocol/#flash-the-silicon-labs-radio-firmware" + ), + HardwareType.SKYCONNECT: ( + "https://skyconnect.home-assistant.io/procedures/disable-multiprotocol/#step-flash-the-silicon-labs-radio-firmware" + ), + HardwareType.OTHER: None, +} + +ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED = "wrong_silabs_firmware_installed" + + +def _detect_radio_hardware(hass: HomeAssistant, device: str) -> HardwareType: + """Identify the radio hardware with the given serial port.""" + try: + yellow_hardware.async_info(hass) + except HomeAssistantError: + pass + else: + if device == YELLOW_RADIO_DEVICE: + return HardwareType.YELLOW + + try: + info = skyconnect_hardware.async_info(hass) + except HomeAssistantError: + pass + else: + for hardware_info in info: + for entry_id in hardware_info.config_entries or []: + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is not None and entry.data["device"] == device: + return HardwareType.SKYCONNECT + + return HardwareType.OTHER + + +async def probe_silabs_firmware_type(device: str) -> ApplicationType | None: + """Probe the running firmware on a Silabs device.""" + flasher = Flasher(device=device) + + try: + await flasher.probe_app_type() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Failed to probe application type", exc_info=True) + + return flasher.app_type + + +async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> bool: + """Create a repair issue if the wrong type of SiLabs firmware is detected.""" + # Only consider actual serial ports + if device.startswith("socket://"): + return False + + app_type = await probe_silabs_firmware_type(device) + + if app_type is None: + # Failed to probe, we can't tell if the wrong firmware is installed + return False + + if app_type == ApplicationType.EZSP: + # If connecting fails but we somehow probe EZSP (e.g. stuck in bootloader), + # reconnect, it should work + raise AlreadyRunningEZSP() + + hardware_type = _detect_radio_hardware(hass, device) + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + is_fixable=False, + is_persistent=True, + learn_more_url=DISABLE_MULTIPAN_URL[hardware_type], + severity=ir.IssueSeverity.ERROR, + translation_key=( + ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED + + ("_nabucasa" if hardware_type != HardwareType.OTHER else "_other") + ), + translation_placeholders={"firmware_type": app_type.name}, + ) + + return True + + +def async_delete_blocking_issues(hass: HomeAssistant) -> None: + """Delete repair issues that should disappear on a successful startup.""" + ir.async_delete_issue(hass, DOMAIN, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 3829ee68bb5..87738e821ea 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -75,7 +75,8 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "This device is not a zha device", - "usb_probe_failed": "Failed to probe the usb device" + "usb_probe_failed": "Failed to probe the usb device", + "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this." } }, "options": { @@ -168,7 +169,8 @@ "abort": { "single_instance_allowed": "[%key:component::zha::config::abort::single_instance_allowed%]", "not_zha_device": "[%key:component::zha::config::abort::not_zha_device%]", - "usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]" + "usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]", + "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]" } }, "config_panel": { @@ -502,5 +504,15 @@ } } } + }, + "issues": { + "wrong_silabs_firmware_installed_nabucasa": { + "title": "Zigbee radio with multiprotocol firmware detected", + "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n -. Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." + }, + "wrong_silabs_firmware_installed_other": { + "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", + "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). To run your radio exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee radio manufacturer's instructions for how to do this." + } } } diff --git a/requirements_all.txt b/requirements_all.txt index c63c6e93ca9..f5246781d77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2611,6 +2611,9 @@ unifi-discovery==1.1.7 # homeassistant.components.unifiled unifiled==0.11 +# homeassistant.components.zha +universal-silabs-flasher==0.0.13 + # homeassistant.components.upb upb-lib==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1693b493962..231f4658e06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1908,6 +1908,9 @@ ultraheat-api==0.5.1 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 +# homeassistant.components.zha +universal-silabs-flasher==0.0.13 + # homeassistant.components.upb upb-lib==0.5.4 diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index c0733841ed5..31fd31dfc96 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -149,6 +149,7 @@ IGNORE_VIOLATIONS = { ("http", "network"), # This would be a circular dep ("zha", "homeassistant_hardware"), + ("zha", "homeassistant_sky_connect"), ("zha", "homeassistant_yellow"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index f690a5152fc..4778f3216da 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -1,5 +1,5 @@ """Test configuration for the ZHA component.""" -from collections.abc import Callable +from collections.abc import Callable, Generator import itertools import time from unittest.mock import AsyncMock, MagicMock, patch @@ -155,10 +155,10 @@ def zigpy_app_controller(): @pytest.fixture(name="config_entry") -async def config_entry_fixture(hass): +async def config_entry_fixture(hass) -> MockConfigEntry: """Fixture representing a config entry.""" - entry = MockConfigEntry( - version=2, + return MockConfigEntry( + version=3, domain=zha_const.DOMAIN, data={ zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB0"}, @@ -178,23 +178,30 @@ async def config_entry_fixture(hass): } }, ) - entry.add_to_hass(hass) - return entry @pytest.fixture -def setup_zha(hass, config_entry, zigpy_app_controller): +def mock_zigpy_connect( + zigpy_app_controller: ControllerApplication, +) -> Generator[ControllerApplication, None, None]: + """Patch the zigpy radio connection with our mock application.""" + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_app: + yield mock_app + + +@pytest.fixture +def setup_zha(hass, config_entry: MockConfigEntry, mock_zigpy_connect): """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} - p1 = patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ) - async def _setup(config=None): + config_entry.add_to_hass(hass) config = config or {} - with p1: + + with mock_zigpy_connect: status = await async_setup_component( hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}} ) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 8e071247872..77d8a615c72 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -26,6 +26,7 @@ from homeassistant.components.zha.core.const import ( EZSP_OVERWRITE_EUI64, RadioType, ) +from homeassistant.components.zha.radio_manager import ProbeResult from homeassistant.config_entries import ( SOURCE_SSDP, SOURCE_USB, @@ -114,7 +115,10 @@ def backup(make_backup): return make_backup() -def mock_detect_radio_type(radio_type=RadioType.ezsp, ret=True): +def mock_detect_radio_type( + radio_type: RadioType = RadioType.ezsp, + ret: ProbeResult = ProbeResult.RADIO_TYPE_DETECTED, +): """Mock `detect_radio_type` that just sets the appropriate attributes.""" async def detect(self): @@ -489,8 +493,11 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None } -@patch(f"bellows.{PROBE_FUNCTION_PATH}", return_value=False) -async def test_discovery_via_usb_no_radio(probe_mock, hass: HomeAssistant) -> None: +@patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + AsyncMock(return_value=ProbeResult.PROBING_FAILED), +) +async def test_discovery_via_usb_no_radio(hass: HomeAssistant) -> None: """Test usb flow -- no radio detected.""" discovery_info = usb.UsbServiceInfo( device="/dev/null", @@ -759,7 +766,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: @patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", - mock_detect_radio_type(ret=False), + AsyncMock(return_value=ProbeResult.PROBING_FAILED), ) @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) async def test_user_flow_not_detected(hass: HomeAssistant) -> None: @@ -851,6 +858,7 @@ async def test_detect_radio_type_success( handler = config_flow.ZhaConfigFlowHandler() handler._radio_mgr.device_path = "/dev/null" + handler.hass = hass await handler._radio_mgr.detect_radio_type() @@ -879,6 +887,8 @@ async def test_detect_radio_type_success_with_settings( handler = config_flow.ZhaConfigFlowHandler() handler._radio_mgr.device_path = "/dev/null" + handler.hass = hass + await handler._radio_mgr.detect_radio_type() assert handler._radio_mgr.radio_type == RadioType.ezsp @@ -956,22 +966,10 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: ], ) async def test_migration_ti_cc_to_znp( - old_type, new_type, hass: HomeAssistant, config_entry + old_type, new_type, hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test zigpy-cc to zigpy-znp config migration.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=old_type + new_type, - data={ - CONF_RADIO_TYPE: old_type, - CONF_DEVICE: { - CONF_DEVICE_PATH: "/dev/ttyUSB1", - CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, - }, - }, - ) - + config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} config_entry.version = 2 config_entry.add_to_hass(hass) @@ -1919,3 +1917,44 @@ async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> result["data_schema"].schema["path"].container[0] == "socket://core-silabs-multiprotocol:9999 - Multiprotocol add-on - Nabu Casa" ) + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_probe_wrong_firmware_installed(hass: HomeAssistant) -> None: + """Test auto-probing failing because the wrong firmware is installed.""" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + return_value=ProbeResult.WRONG_FIRMWARE_INSTALLED, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: "choose_serial_port"}, + data={ + CONF_DEVICE_PATH: ( + "/dev/ttyUSB1234 - Some serial port, s/n: 1234 - Virtual serial port" + ) + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_firmware_installed" + + +async def test_discovery_wrong_firmware_installed(hass: HomeAssistant) -> None: + """Test auto-probing failing because the wrong firmware is installed.""" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + return_value=ProbeResult.WRONG_FIRMWARE_INSTALLED, + ), patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: "confirm"}, + data={}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_firmware_installed" diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 0bb06ea723b..6bcb321ab14 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -15,6 +15,7 @@ from homeassistant.helpers.device_registry import async_get from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import MockConfigEntry from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -57,7 +58,7 @@ def zigpy_device(zigpy_device_mock): async def test_diagnostics_for_config_entry( hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry, + config_entry: MockConfigEntry, zha_device_joined, zigpy_device, ) -> None: @@ -86,12 +87,11 @@ async def test_diagnostics_for_config_entry( async def test_diagnostics_for_device( hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry, + config_entry: MockConfigEntry, zha_device_joined, zigpy_device, ) -> None: """Test diagnostics for device.""" - zha_device: ZHADevice = await zha_device_joined(zigpy_device) dev_reg = async_get(hass) device = dev_reg.async_get_device(identifiers={("zha", str(zha_device.ieee))}) diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index c507db3e6ab..7acf9219d67 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -14,6 +14,7 @@ from homeassistant import config_entries from homeassistant.components.usb import UsbServiceInfo from homeassistant.components.zha import radio_manager from homeassistant.components.zha.core.const import DOMAIN, RadioType +from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -59,10 +60,13 @@ def backup(): return backup -def mock_detect_radio_type(radio_type=RadioType.ezsp, ret=True): +def mock_detect_radio_type( + radio_type: RadioType = RadioType.ezsp, + ret: ProbeResult = ProbeResult.RADIO_TYPE_DETECTED, +): """Mock `detect_radio_type` that just sets the appropriate attributes.""" - async def detect(self): + async def detect(self) -> ProbeResult: self.radio_type = radio_type self.device_settings = radio_type.controller.SCHEMA_DEVICE( {CONF_DEVICE_PATH: self.device_path} @@ -421,3 +425,58 @@ async def test_migrate_initiate_failure( await migration_helper.async_initiate_migration(migration_data) assert len(mock_load_info.mock_calls) == radio_manager.BACKUP_RETRIES + + +@pytest.fixture(name="radio_manager") +def zha_radio_manager(hass: HomeAssistant) -> ZhaRadioManager: + """Fixture for an instance of `ZhaRadioManager`.""" + radio_manager = ZhaRadioManager() + radio_manager.hass = hass + radio_manager.device_path = "/dev/ttyZigbee" + return radio_manager + + +async def test_detect_radio_type_success(radio_manager: ZhaRadioManager) -> None: + """Test radio type detection, success.""" + with patch( + "bellows.zigbee.application.ControllerApplication.probe", return_value=False + ), patch( + # Intentionally probe only the second radio type + "zigpy_znp.zigbee.application.ControllerApplication.probe", + return_value=True, + ): + assert ( + await radio_manager.detect_radio_type() == ProbeResult.RADIO_TYPE_DETECTED + ) + assert radio_manager.radio_type == RadioType.znp + + +async def test_detect_radio_type_failure_wrong_firmware( + radio_manager: ZhaRadioManager, +) -> None: + """Test radio type detection, wrong firmware.""" + with patch( + "homeassistant.components.zha.radio_manager.AUTOPROBE_RADIOS", () + ), patch( + "homeassistant.components.zha.radio_manager.repairs.warn_on_wrong_silabs_firmware", + return_value=True, + ): + assert ( + await radio_manager.detect_radio_type() + == ProbeResult.WRONG_FIRMWARE_INSTALLED + ) + assert radio_manager.radio_type is None + + +async def test_detect_radio_type_failure_no_detect( + radio_manager: ZhaRadioManager, +) -> None: + """Test radio type detection, no firmware detected.""" + with patch( + "homeassistant.components.zha.radio_manager.AUTOPROBE_RADIOS", () + ), patch( + "homeassistant.components.zha.radio_manager.repairs.warn_on_wrong_silabs_firmware", + return_value=False, + ): + assert await radio_manager.detect_radio_type() == ProbeResult.PROBING_FAILED + assert radio_manager.radio_type is None diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py new file mode 100644 index 00000000000..18705168a3f --- /dev/null +++ b/tests/components/zha/test_repairs.py @@ -0,0 +1,235 @@ +"""Test ZHA repairs.""" +from collections.abc import Callable +import logging +from unittest.mock import patch + +import pytest +from universal_silabs_flasher.const import ApplicationType +from universal_silabs_flasher.flasher import Flasher + +from homeassistant.components.homeassistant_sky_connect import ( + DOMAIN as SKYCONNECT_DOMAIN, +) +from homeassistant.components.zha.core.const import DOMAIN +from homeassistant.components.zha.repairs import ( + DISABLE_MULTIPAN_URL, + ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + HardwareType, + _detect_radio_hardware, + probe_silabs_firmware_type, + warn_on_wrong_silabs_firmware, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + +SKYCONNECT_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0" + + +def set_flasher_app_type(app_type: ApplicationType) -> Callable[[Flasher], None]: + """Set the app type on the flasher.""" + + def replacement(self: Flasher) -> None: + self.app_type = app_type + + return replacement + + +def test_detect_radio_hardware(hass: HomeAssistant) -> None: + """Test logic to detect radio hardware.""" + skyconnect_config_entry = MockConfigEntry( + data={ + "device": SKYCONNECT_DEVICE, + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "description": "SkyConnect v1.0", + }, + domain=SKYCONNECT_DOMAIN, + options={}, + title="Home Assistant SkyConnect", + ) + skyconnect_config_entry.add_to_hass(hass) + + assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.SKYCONNECT + assert ( + _detect_radio_hardware(hass, SKYCONNECT_DEVICE + "_foo") == HardwareType.OTHER + ) + assert _detect_radio_hardware(hass, "/dev/ttyAMA1") == HardwareType.OTHER + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.get_os_info", + return_value={"board": "yellow"}, + ): + assert _detect_radio_hardware(hass, "/dev/ttyAMA1") == HardwareType.YELLOW + assert _detect_radio_hardware(hass, "/dev/ttyAMA2") == HardwareType.OTHER + assert ( + _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.SKYCONNECT + ) + + +def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: + """Test radio hardware detection failure.""" + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.async_info", + side_effect=HomeAssistantError(), + ), patch( + "homeassistant.components.homeassistant_sky_connect.hardware.async_info", + side_effect=HomeAssistantError(), + ): + assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER + + +@pytest.mark.parametrize( + ("detected_hardware", "expected_learn_more_url"), + [ + (HardwareType.SKYCONNECT, DISABLE_MULTIPAN_URL[HardwareType.SKYCONNECT]), + (HardwareType.YELLOW, DISABLE_MULTIPAN_URL[HardwareType.YELLOW]), + (HardwareType.OTHER, None), + ], +) +async def test_multipan_firmware_repair( + hass: HomeAssistant, + detected_hardware: HardwareType, + expected_learn_more_url: str, + config_entry: MockConfigEntry, + mock_zigpy_connect, +) -> None: + """Test creating a repair when multi-PAN firmware is installed and probed.""" + + config_entry.add_to_hass(hass) + + # ZHA fails to set up + with patch( + "homeassistant.components.zha.repairs.Flasher.probe_app_type", + side_effect=set_flasher_app_type(ApplicationType.CPC), + autospec=True, + ), patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=RuntimeError(), + ), patch( + "homeassistant.components.zha.repairs._detect_radio_hardware", + return_value=detected_hardware, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + await hass.config_entries.async_unload(config_entry.entry_id) + + issue_registry = ir.async_get(hass) + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + ) + + # The issue is created when we fail to probe + assert issue is not None + assert issue.translation_placeholders["firmware_type"] == "CPC" + assert issue.learn_more_url == expected_learn_more_url + + # If ZHA manages to start up normally after this, the issue will be deleted + with mock_zigpy_connect: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + ) + assert issue is None + + +async def test_multipan_firmware_no_repair_on_probe_failure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that a repair is not created when multi-PAN firmware cannot be probed.""" + + config_entry.add_to_hass(hass) + + # ZHA fails to set up + with patch( + "homeassistant.components.zha.repairs.Flasher.probe_app_type", + side_effect=set_flasher_app_type(None), + autospec=True, + ), patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=RuntimeError(), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + await hass.config_entries.async_unload(config_entry.entry_id) + + # No repair is created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + ) + assert issue is None + + +async def test_multipan_firmware_retry_on_probe_ezsp( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect, +) -> None: + """Test that ZHA is reloaded when EZSP firmware is probed.""" + + config_entry.add_to_hass(hass) + + # ZHA fails to set up + with patch( + "homeassistant.components.zha.repairs.Flasher.probe_app_type", + side_effect=set_flasher_app_type(ApplicationType.EZSP), + autospec=True, + ), patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=RuntimeError(), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # The config entry state is `SETUP_RETRY`, not `SETUP_ERROR`! + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + await hass.config_entries.async_unload(config_entry.entry_id) + + # No repair is created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + ) + assert issue is None + + +async def test_no_warn_on_socket(hass: HomeAssistant) -> None: + """Test that no warning is issued when the device is a socket.""" + with patch( + "homeassistant.components.zha.repairs.probe_silabs_firmware_type", autospec=True + ) as mock_probe: + await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678") + + mock_probe.assert_not_called() + + +async def test_probe_failure_exception_handling(caplog) -> None: + """Test that probe failures are handled gracefully.""" + with patch( + "homeassistant.components.zha.repairs.Flasher.probe_app_type", + side_effect=RuntimeError(), + ), caplog.at_level(logging.DEBUG): + await probe_silabs_firmware_type("/dev/ttyZigbee") + + assert "Failed to probe application type" in caplog.text From ac0565e3bc031387fb82161e03e6f20b86e54f02 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 1 Sep 2023 15:58:01 +0200 Subject: [PATCH 038/984] Use common key for away mode state translations (#99425) --- homeassistant/components/water_heater/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 6991d371bd3..1b3af02610c 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -21,8 +21,8 @@ "away_mode": { "name": "Away mode", "state": { - "off": "Off", - "on": "On" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" } } } From 390c046537dba002c3498321114c9c46ab38a784 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 1 Sep 2023 17:14:42 +0200 Subject: [PATCH 039/984] Fix template helper strings (#99456) --- homeassistant/components/template/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 482682d0ce1..7e5e56a26d6 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -5,7 +5,7 @@ "data": { "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", "name": "[%key:common::config_flow::data::name%]", - "state_template": "[%key:component::template::config::step::sensor::data::state_template%]" + "state": "[%key:component::template::config::step::sensor::data::state%]" }, "title": "Template binary sensor" }, @@ -14,7 +14,7 @@ "device_class": "Device class", "name": "[%key:common::config_flow::data::name%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", - "state_template": "State template", + "state": "State template", "unit_of_measurement": "Unit of measurement" }, "title": "Template sensor" @@ -34,7 +34,7 @@ "binary_sensor": { "data": { "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", - "state_template": "[%key:component::template::config::step::sensor::data::state_template%]" + "state": "[%key:component::template::config::step::sensor::data::state%]" }, "title": "[%key:component::template::config::step::binary_sensor::title%]" }, @@ -42,7 +42,7 @@ "data": { "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", "state_class": "[%key:component::template::config::step::sensor::data::state_class%]", - "state_template": "[%key:component::template::config::step::sensor::data::state_template%]", + "state": "[%key:component::template::config::step::sensor::data::state%]", "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" }, "title": "[%key:component::template::config::step::sensor::title%]" From 1d80af870d0a50c8fe360fe3f6cfb797169c3d51 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 1 Sep 2023 17:28:52 +0200 Subject: [PATCH 040/984] Update frontend to 20230901.0 (#99464) --- 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 a31faaf362e..3b46f568d3e 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==20230831.0"] + "requirements": ["home-assistant-frontend==20230901.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3dccb80d11e..19169de83f6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230831.0 +home-assistant-frontend==20230901.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f5246781d77..9a227358391 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -994,7 +994,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230831.0 +home-assistant-frontend==20230901.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 231f4658e06..e34cc14052f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -777,7 +777,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230831.0 +home-assistant-frontend==20230901.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 169a318ec4a9ec4fab87e85e2c4952dda397c0e0 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 1 Sep 2023 12:06:37 -0400 Subject: [PATCH 041/984] Fix device name in zwave_js repair flow (#99414) --- homeassistant/components/zwave_js/__init__.py | 9 +++------ homeassistant/components/zwave_js/repairs.py | 17 +++++++++++------ tests/components/zwave_js/test_repairs.py | 1 + 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 2d158f47e44..b56298e36ba 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -600,19 +600,16 @@ class NodeEvents: # device config has changed, and if so, issue a repair registry entry for a # possible reinterview if not node.is_controller_node and await node.async_has_device_config_changed(): + device_name = device.name_by_user or device.name or "Unnamed device" async_create_issue( self.hass, DOMAIN, f"device_config_file_changed.{device.id}", - data={"device_id": device.id}, + data={"device_id": device.id, "device_name": device_name}, is_fixable=True, is_persistent=False, translation_key="device_config_file_changed", - translation_placeholders={ - "device_name": device.name_by_user - or device.name - or "Unnamed device" - }, + translation_placeholders={"device_name": device_name}, severity=IssueSeverity.WARNING, ) diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index 58781941b09..89f51dddb88 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -1,8 +1,6 @@ """Repairs for Z-Wave JS.""" from __future__ import annotations -from typing import cast - import voluptuous as vol from zwave_js_server.model.node import Node @@ -16,9 +14,10 @@ from .helpers import async_get_node_from_device_id class DeviceConfigFileChangedFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, node: Node) -> None: + def __init__(self, node: Node, device_name: str) -> None: """Initialize.""" self.node = node + self.device_name = device_name async def async_step_init( self, user_input: dict[str, str] | None = None @@ -34,17 +33,23 @@ class DeviceConfigFileChangedFlow(RepairsFlow): self.hass.async_create_task(self.node.async_refresh_info()) return self.async_create_entry(title="", data={}) - return self.async_show_form(step_id="confirm", data_schema=vol.Schema({})) + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={"device_name": self.device_name}, + ) async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, - data: dict[str, str | int | float | None] | None, + data: dict[str, str] | None, ) -> RepairsFlow: """Create flow.""" + if issue_id.split(".")[0] == "device_config_file_changed": + assert data return DeviceConfigFileChangedFlow( - async_get_node_from_device_id(hass, cast(dict, data)["device_id"]) + async_get_node_from_device_id(hass, data["device_id"]), data["device_name"] ) return ConfirmRepairFlow() diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index b1702900d7c..07371a299ef 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -77,6 +77,7 @@ async def test_device_config_file_changed( flow_id = data["flow_id"] assert data["step_id"] == "confirm" + assert data["description_placeholders"] == {"device_name": device.name} # Apply fix url = RepairsFlowResourceView.url.format(flow_id=flow_id) From 1e6cddaa1d4e1eb5b983800298bd6557d1933e5e Mon Sep 17 00:00:00 2001 From: Richard Mikalsen Date: Fri, 1 Sep 2023 19:05:35 +0200 Subject: [PATCH 042/984] Turn off Mill heaters using local API (#99348) * Update mill_local * Add ability to turn heater on and off * Use OperationMode from upstream library * Fix: compare against value --- homeassistant/components/mill/climate.py | 24 ++++++++++++++++----- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 2ddcf97f25a..a5e59b4f8ec 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -2,6 +2,7 @@ from typing import Any import mill +from mill_local import OperationMode import voluptuous as vol from homeassistant.components.climate import ( @@ -176,8 +177,7 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit """Representation of a Mill Thermostat device.""" _attr_has_entity_name = True - _attr_hvac_mode = HVACMode.HEAT - _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None @@ -210,6 +210,15 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit ) await self.coordinator.async_request_refresh() + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.HEAT: + await self.coordinator.mill_data_connection.set_operation_mode_control_individually() + await self.coordinator.async_request_refresh() + elif hvac_mode == HVACMode.OFF: + await self.coordinator.mill_data_connection.set_operation_mode_off() + await self.coordinator.async_request_refresh() + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -222,7 +231,12 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit self._attr_target_temperature = data["set_temperature"] self._attr_current_temperature = data["ambient_temperature"] - if data["current_power"] > 0: - self._attr_hvac_action = HVACAction.HEATING + if data["operation_mode"] == OperationMode.OFF.value: + self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_action = HVACAction.OFF else: - self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.HEAT + if data["current_power"] > 0: + self._attr_hvac_action = HVACAction.HEATING + else: + self._attr_hvac_action = HVACAction.IDLE diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index b2dbf993dae..39b91570190 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.1", "mill-local==0.2.0"] + "requirements": ["millheater==0.11.1", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a227358391..c84a21f3d8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1210,7 +1210,7 @@ mficlient==0.3.0 micloud==0.5 # homeassistant.components.mill -mill-local==0.2.0 +mill-local==0.3.0 # homeassistant.components.mill millheater==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e34cc14052f..ef67aca2937 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -924,7 +924,7 @@ mficlient==0.3.0 micloud==0.5 # homeassistant.components.mill -mill-local==0.2.0 +mill-local==0.3.0 # homeassistant.components.mill millheater==0.11.1 From cf59ea3c47a3491508be20a3950437e1d538c573 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 1 Sep 2023 19:27:54 +0200 Subject: [PATCH 043/984] Use snapshot assertion for netatmo diagnostics test (#99159) --- .../netatmo/snapshots/test_diagnostics.ambr | 620 ++++++++++++++++++ tests/components/netatmo/test_diagnostics.py | 88 +-- 2 files changed, 630 insertions(+), 78 deletions(-) create mode 100644 tests/components/netatmo/snapshots/test_diagnostics.ambr diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..228fc7563e0 --- /dev/null +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -0,0 +1,620 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'account': dict({ + 'errors': list([ + ]), + 'homes': list([ + dict({ + 'altitude': 112, + 'coordinates': '**REDACTED**', + 'country': 'DE', + 'id': '91763b24c43d3e344f424e8b', + 'modules': list([ + dict({ + 'id': '12:34:56:00:fa:d0', + 'modules_bridged': list([ + '12:34:56:00:01:ae', + '12:34:56:03:a0:ac', + '12:34:56:03:a5:54', + ]), + 'name': '**REDACTED**', + 'setup_date': 1494963356, + 'type': 'NAPlug', + }), + dict({ + 'bridge': '12:34:56:00:fa:d0', + 'id': '12:34:56:00:01:ae', + 'name': '**REDACTED**', + 'room_id': '2746182631', + 'setup_date': 1494963356, + 'type': 'NATherm1', + }), + dict({ + 'bridge': '12:34:56:00:fa:d0', + 'id': '12:34:56:03:a5:54', + 'name': '**REDACTED**', + 'room_id': '2833524037', + 'setup_date': 1554549767, + 'type': 'NRV', + }), + dict({ + 'bridge': '12:34:56:00:fa:d0', + 'id': '12:34:56:03:a0:ac', + 'name': '**REDACTED**', + 'room_id': '2940411577', + 'setup_date': 1554554444, + 'type': 'NRV', + }), + dict({ + 'id': '12:34:56:00:f1:62', + 'modules_bridged': list([ + '12:34:56:00:86:99', + '12:34:56:00:e3:9b', + ]), + 'name': '**REDACTED**', + 'room_id': '3688132631', + 'setup_date': 1544828430, + 'type': 'NACamera', + }), + dict({ + 'customer_id': '1000010', + 'hk_device_id': '123456007df1', + 'id': '12:34:56:10:f1:66', + 'name': '**REDACTED**', + 'network_lock': False, + 'quick_display_zone': 62, + 'reachable': True, + 'room_id': '3688132631', + 'setup_date': 1602691361, + 'type': 'NDB', + }), + dict({ + 'customer_id': 'A00010', + 'id': '12:34:56:10:b9:0e', + 'name': '**REDACTED**', + 'network_lock': False, + 'reachable': True, + 'setup_date': 1509290599, + 'type': 'NOC', + 'use_pincode': False, + }), + dict({ + 'capabilities': list([ + dict({ + 'available': True, + 'name': '**REDACTED**', + }), + ]), + 'hk_device_id': '12:34:56:20:d0:c5', + 'id': '12:34:56:20:f5:44', + 'max_modules_nb': 21, + 'modules_bridged': list([ + '12:34:56:20:f5:8c', + ]), + 'name': '**REDACTED**', + 'reachable': True, + 'room_id': '222452125', + 'setup_date': 1607443936, + 'type': 'OTH', + }), + dict({ + 'bridge': '12:34:56:20:f5:44', + 'id': '12:34:56:20:f5:8c', + 'name': '**REDACTED**', + 'room_id': '222452125', + 'setup_date': 1607443939, + 'type': 'OTM', + }), + dict({ + 'id': '12:34:56:30:d5:d4', + 'modules_bridged': list([ + '0009999992', + ]), + 'name': '**REDACTED**', + 'room_id': '222452125', + 'setup_date': 1562262465, + 'type': 'NBG', + }), + dict({ + 'bridge': '12:34:56:30:d5:d4', + 'id': '0009999992', + 'name': '**REDACTED**', + 'room_id': '3688132631', + 'setup_date': 1578551339, + 'type': 'NBR', + }), + dict({ + 'alarm_config': dict({ + 'default_alarm': list([ + dict({ + 'db_alarm_number': 0, + }), + dict({ + 'db_alarm_number': 1, + }), + dict({ + 'db_alarm_number': 2, + }), + dict({ + 'db_alarm_number': 6, + }), + dict({ + 'db_alarm_number': 4, + }), + dict({ + 'db_alarm_number': 5, + }), + dict({ + 'db_alarm_number': 7, + }), + dict({ + 'db_alarm_number': 22, + }), + ]), + 'personnalized': list([ + dict({ + 'data_type': 1, + 'db_alarm_number': 8, + 'direction': 0, + 'threshold': 20, + }), + dict({ + 'data_type': 1, + 'db_alarm_number': 9, + 'direction': 1, + 'threshold': 17, + }), + dict({ + 'data_type': 4, + 'db_alarm_number': 16, + 'direction': 0, + 'threshold': 65, + }), + dict({ + 'data_type': 8, + 'db_alarm_number': 22, + 'direction': 0, + 'threshold': 19, + }), + ]), + }), + 'customer_id': 'C00016', + 'hardware_version': 251, + 'id': '12:34:56:80:bb:26', + 'module_offset': dict({ + '03:00:00:03:1b:0e': dict({ + 'a': 0, + }), + '12:34:56:80:bb:26': dict({ + 'a': 0.1, + }), + }), + 'modules_bridged': list([ + '12:34:56:80:44:92', + '12:34:56:80:7e:18', + '12:34:56:80:1c:42', + '12:34:56:80:c1:ea', + ]), + 'name': '**REDACTED**', + 'public_ext_counter': 0, + 'public_ext_data': False, + 'reachable': True, + 'room_id': '4122897288', + 'setup_date': 1419453350, + 'type': 'NAMain', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:80:1c:42', + 'name': '**REDACTED**', + 'setup_date': 1448565785, + 'type': 'NAModule1', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:80:c1:ea', + 'name': '**REDACTED**', + 'setup_date': 1591770206, + 'type': 'NAModule3', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:80:44:92', + 'name': '**REDACTED**', + 'setup_date': 1484997703, + 'type': 'NAModule4', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:80:7e:18', + 'name': '**REDACTED**', + 'setup_date': 1543579864, + 'type': 'NAModule4', + }), + dict({ + 'bridge': '12:34:56:80:bb:26', + 'id': '12:34:56:03:1b:e4', + 'name': '**REDACTED**', + 'setup_date': 1543579864, + 'type': 'NAModule2', + }), + dict({ + 'id': '12:34:56:80:60:40', + 'modules_bridged': list([ + '12:34:56:80:00:12:ac:f2', + '12:34:56:80:00:c3:69:3c', + '12:34:56:00:00:a1:4c:da', + '12:34:56:00:01:01:01:a1', + '00:11:22:33:00:11:45:fe', + ]), + 'name': '**REDACTED**', + 'room_id': '1310352496', + 'setup_date': 1641841257, + 'type': 'NLG', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:80:00:12:ac:f2', + 'name': '**REDACTED**', + 'room_id': '1310352496', + 'setup_date': 1641841262, + 'type': 'NLP', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:80:00:c3:69:3c', + 'name': '**REDACTED**', + 'setup_date': 1641841262, + 'type': 'NLT', + }), + dict({ + 'bridge': '12:34:56:00:f1:62', + 'category': 'window', + 'id': '12:34:56:00:86:99', + 'name': '**REDACTED**', + 'setup_date': 1581177375, + 'type': 'NACamDoorTag', + }), + dict({ + 'bridge': '12:34:56:00:f1:62', + 'id': '12:34:56:00:e3:9b', + 'name': '**REDACTED**', + 'setup_date': 1620479901, + 'type': 'NIS', + }), + dict({ + 'id': '12:34:56:00:16:0e', + 'modules_bridged': list([ + '12:34:56:00:16:0e#0', + '12:34:56:00:16:0e#1', + '12:34:56:00:16:0e#2', + '12:34:56:00:16:0e#3', + '12:34:56:00:16:0e#4', + '12:34:56:00:16:0e#5', + '12:34:56:00:16:0e#6', + '12:34:56:00:16:0e#7', + '12:34:56:00:16:0e#8', + ]), + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496884, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#0', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#1', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#2', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#3', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#4', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#5', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#6', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#7', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:00:16:0e', + 'id': '12:34:56:00:16:0e#8', + 'name': '**REDACTED**', + 'room_id': '100007519', + 'setup_date': 1644496886, + 'type': 'NLE', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:00:00:a1:4c:da', + 'name': '**REDACTED**', + 'room_id': '100008999', + 'setup_date': 1638376602, + 'type': 'NLPC', + }), + dict({ + 'id': '10:20:30:bd:b8:1e', + 'name': '**REDACTED**', + 'room_id': '1002003001', + 'setup_date': 1638022197, + 'type': 'BNS', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'brightness': 63, + 'firmware_revision': 57, + 'id': '00:11:22:33:00:11:45:fe', + 'last_seen': 1657086939, + 'on': False, + 'power': 0, + 'reachable': True, + 'type': 'NLF', + }), + dict({ + 'bridge': '12:34:56:80:60:40', + 'id': '12:34:56:00:01:01:01:a1', + 'name': '**REDACTED**', + 'room_id': '1002003001', + 'setup_date': 1598367404, + 'type': 'NLFN', + }), + ]), + 'name': '**REDACTED**', + 'persons': list([ + dict({ + 'id': '91827374-7e04-5298-83ad-a0cb8372dff1', + 'pseudo': '**REDACTED**', + 'url': '**REDACTED**', + }), + dict({ + 'id': '91827375-7e04-5298-83ae-a0cb8372dff2', + 'pseudo': '**REDACTED**', + 'url': '**REDACTED**', + }), + dict({ + 'id': '91827376-7e04-5298-83af-a0cb8372dff3', + 'pseudo': '**REDACTED**', + 'url': '**REDACTED**', + }), + ]), + 'rooms': list([ + dict({ + 'id': '2746182631', + 'module_ids': list([ + '12:34:56:00:01:ae', + ]), + 'name': '**REDACTED**', + 'type': 'livingroom', + }), + dict({ + 'id': '3688132631', + 'module_ids': list([ + '12:34:56:00:f1:62', + '12:34:56:10:f1:66', + '12:34:56:00:e3:9b', + '0009999992', + ]), + 'name': '**REDACTED**', + 'type': 'custom', + }), + dict({ + 'id': '2833524037', + 'module_ids': list([ + '12:34:56:03:a5:54', + ]), + 'name': '**REDACTED**', + 'type': 'lobby', + }), + dict({ + 'id': '2940411577', + 'module_ids': list([ + '12:34:56:03:a0:ac', + ]), + 'name': '**REDACTED**', + 'type': 'kitchen', + }), + dict({ + 'id': '222452125', + 'module_ids': list([ + '12:34:56:20:f5:44', + '12:34:56:20:f5:8c', + ]), + 'modules': list([ + '12:34:56:20:f5:44', + '12:34:56:20:f5:8c', + ]), + 'name': '**REDACTED**', + 'therm_relay': '12:34:56:20:f5:44', + 'true_temperature_available': True, + 'type': 'electrical_cabinet', + }), + dict({ + 'id': '100007519', + 'module_ids': list([ + '12:34:56:00:16:0e', + '12:34:56:00:16:0e#0', + '12:34:56:00:16:0e#1', + '12:34:56:00:16:0e#2', + '12:34:56:00:16:0e#3', + '12:34:56:00:16:0e#4', + '12:34:56:00:16:0e#5', + '12:34:56:00:16:0e#6', + '12:34:56:00:16:0e#7', + '12:34:56:00:16:0e#8', + ]), + 'name': '**REDACTED**', + 'type': 'electrical_cabinet', + }), + dict({ + 'id': '1002003001', + 'module_ids': list([ + '10:20:30:bd:b8:1e', + ]), + 'name': '**REDACTED**', + 'type': 'corridor', + }), + dict({ + 'id': '100007520', + 'module_ids': list([ + '00:11:22:33:00:11:45:fe', + ]), + 'name': '**REDACTED**', + 'type': 'toilets', + }), + ]), + 'schedules': list([ + dict({ + 'away_temp': 14, + 'hg_temp': 7, + 'id': '591b54a2764ff4d50d8b5795', + 'name': '**REDACTED**', + 'selected': True, + 'timetable': '**REDACTED**', + 'type': 'therm', + 'zones': '**REDACTED**', + }), + dict({ + 'away_temp': 14, + 'hg_temp': 7, + 'id': 'b1b54a2f45795764f59d50d8', + 'name': '**REDACTED**', + 'timetable': '**REDACTED**', + 'type': 'therm', + 'zones': '**REDACTED**', + }), + ]), + 'therm_mode': 'schedule', + 'therm_setpoint_default_duration': 120, + 'timezone': 'Europe/Berlin', + }), + dict({ + 'altitude': 112, + 'coordinates': '**REDACTED**', + 'country': 'DE', + 'id': '91763b24c43d3e344f424e8c', + 'therm_mode': 'schedule', + 'therm_setpoint_default_duration': 180, + 'timezone': 'Europe/Berlin', + }), + ]), + }), + }), + 'info': dict({ + 'data': dict({ + 'auth_implementation': 'cloud', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_in': 60, + 'refresh_token': '**REDACTED**', + 'scope': list([ + 'access_camera', + 'access_doorbell', + 'access_presence', + 'read_bubendorff', + 'read_camera', + 'read_carbonmonoxidedetector', + 'read_doorbell', + 'read_homecoach', + 'read_magellan', + 'read_mx', + 'read_presence', + 'read_smarther', + 'read_smokedetector', + 'read_station', + 'read_thermostat', + 'write_bubendorff', + 'write_camera', + 'write_magellan', + 'write_mx', + 'write_presence', + 'write_smarther', + 'write_thermostat', + ]), + 'type': 'Bearer', + }), + 'webhook_id': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'netatmo', + 'options': dict({ + 'weather_areas': dict({ + 'Home avg': dict({ + 'area_name': 'Home avg', + 'lat_ne': '**REDACTED**', + 'lat_sw': '**REDACTED**', + 'lon_ne': '**REDACTED**', + 'lon_sw': '**REDACTED**', + 'mode': 'avg', + 'show_on_map': False, + }), + 'Home max': dict({ + 'area_name': 'Home max', + 'lat_ne': '**REDACTED**', + 'lat_sw': '**REDACTED**', + 'lon_ne': '**REDACTED**', + 'lon_sw': '**REDACTED**', + 'mode': 'max', + 'show_on_map': True, + }), + }), + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'netatmo', + 'version': 1, + 'webhook_registered': False, + }), + }) +# --- diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 6c0c489be3d..0ece935abcb 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -1,7 +1,9 @@ """Test the Netatmo diagnostics.""" from unittest.mock import AsyncMock, patch -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion +from syrupy.filters import paths + from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -12,7 +14,10 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry, ) -> None: """Test config entry diagnostics.""" with patch( @@ -29,79 +34,6 @@ async def test_entry_diagnostics( await hass.async_block_till_done() - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - - # ignore for tests - result["info"]["data"]["token"].pop("expires_at") - result["info"].pop("entry_id") - - assert result["info"] == { - "data": { - "auth_implementation": "cloud", - "token": { - "access_token": REDACTED, - "expires_in": 60, - "refresh_token": REDACTED, - "scope": [ - "access_camera", - "access_doorbell", - "access_presence", - "read_bubendorff", - "read_camera", - "read_carbonmonoxidedetector", - "read_doorbell", - "read_homecoach", - "read_magellan", - "read_mx", - "read_presence", - "read_smarther", - "read_smokedetector", - "read_station", - "read_thermostat", - "write_bubendorff", - "write_camera", - "write_magellan", - "write_mx", - "write_presence", - "write_smarther", - "write_thermostat", - ], - "type": "Bearer", - }, - "webhook_id": REDACTED, - }, - "disabled_by": None, - "domain": "netatmo", - "options": { - "weather_areas": { - "Home avg": { - "area_name": "Home avg", - "lat_ne": REDACTED, - "lat_sw": REDACTED, - "lon_ne": REDACTED, - "lon_sw": REDACTED, - "mode": "avg", - "show_on_map": False, - }, - "Home max": { - "area_name": "Home max", - "lat_ne": REDACTED, - "lat_sw": REDACTED, - "lon_ne": REDACTED, - "lon_sw": REDACTED, - "mode": "max", - "show_on_map": True, - }, - } - }, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "title": "Mock Title", - "unique_id": "netatmo", - "version": 1, - "webhook_registered": False, - } - - for home in result["data"]["account"]["homes"]: - assert home["coordinates"] == REDACTED + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=paths("info.data.token.expires_at", "info.entry_id")) From 09f45660cff850e36e7c9c56f66face5580fb750 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 14:10:47 -0400 Subject: [PATCH 044/984] Avoid linear search of MQTT SUPPORTED_COMPONENTS (#99459) By making this a set we avoid the linear search in async_discovery_message_received --- homeassistant/components/mqtt/discovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 37885b628d2..0002a1866a4 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -48,7 +48,7 @@ TOPIC_MATCHER = re.compile( r"?(?P[a-zA-Z0-9_-]+)/config" ) -SUPPORTED_COMPONENTS = [ +SUPPORTED_COMPONENTS = { "alarm_control_panel", "binary_sensor", "button", @@ -75,7 +75,7 @@ SUPPORTED_COMPONENTS = [ "update", "vacuum", "water_heater", -] +} MQTT_DISCOVERY_UPDATED = "mqtt_discovery_updated_{}" MQTT_DISCOVERY_NEW = "mqtt_discovery_new_{}_{}" From 04bf12642546df9b29b5b7094b486e57f65c6396 Mon Sep 17 00:00:00 2001 From: Andrew Onyshchuk Date: Fri, 1 Sep 2023 13:28:53 -0700 Subject: [PATCH 045/984] Update aiotractive to 0.5.6 (#99477) --- homeassistant/components/tractive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index 9e448d1fd26..75ddf065bd7 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==0.5.5"] + "requirements": ["aiotractive==0.5.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index c84a21f3d8c..b6b801e0e69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -360,7 +360,7 @@ aioswitcher==3.3.0 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.5 +aiotractive==0.5.6 # homeassistant.components.unifi aiounifi==58 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef67aca2937..e71ca77a059 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,7 +335,7 @@ aioswitcher==3.3.0 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.5 +aiotractive==0.5.6 # homeassistant.components.unifi aiounifi==58 From 5a8fc43212c12978b7943a2b6c060ed8b587febd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 16:40:53 -0400 Subject: [PATCH 046/984] Refactor MQTT discovery to avoid creating closure if hash already in discovery_pending_discovered (#99458) --- homeassistant/components/mqtt/discovery.py | 34 ++++++++++++---------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0002a1866a4..b05e57280f3 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -275,32 +275,34 @@ async def async_start( # noqa: C901 _LOGGER.debug("Process discovery payload %s", payload) discovery_hash = (component, discovery_id) - if discovery_hash in mqtt_data.discovery_already_discovered or payload: + + already_discovered = discovery_hash in mqtt_data.discovery_already_discovered + if ( + already_discovered or payload + ) and discovery_hash not in mqtt_data.discovery_pending_discovered: + discovery_pending_discovered = mqtt_data.discovery_pending_discovered @callback def discovery_done(_: Any) -> None: - pending = mqtt_data.discovery_pending_discovered[discovery_hash][ - "pending" - ] + pending = discovery_pending_discovered[discovery_hash]["pending"] _LOGGER.debug("Pending discovery for %s: %s", discovery_hash, pending) if not pending: - mqtt_data.discovery_pending_discovered[discovery_hash]["unsub"]() - mqtt_data.discovery_pending_discovered.pop(discovery_hash) + discovery_pending_discovered[discovery_hash]["unsub"]() + discovery_pending_discovered.pop(discovery_hash) else: payload = pending.pop() async_process_discovery_payload(component, discovery_id, payload) - if discovery_hash not in mqtt_data.discovery_pending_discovered: - mqtt_data.discovery_pending_discovered[discovery_hash] = { - "unsub": async_dispatcher_connect( - hass, - MQTT_DISCOVERY_DONE.format(discovery_hash), - discovery_done, - ), - "pending": deque([]), - } + discovery_pending_discovered[discovery_hash] = { + "unsub": async_dispatcher_connect( + hass, + MQTT_DISCOVERY_DONE.format(discovery_hash), + discovery_done, + ), + "pending": deque([]), + } - if discovery_hash in mqtt_data.discovery_already_discovered: + if already_discovered: # Dispatch update message = f"Component has already been discovered: {component} {discovery_id}, sending update" async_log_discovery_origin_info(message, payload) From 7c87b38a23a50a038e4ad66818e9b51ff745ce86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 16:41:34 -0400 Subject: [PATCH 047/984] Reduce overhead to process and publish MQTT messages (#99457) --- homeassistant/components/mqtt/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 62f1f55401d..733645c4788 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -110,7 +110,7 @@ def publish( encoding: str | None = DEFAULT_ENCODING, ) -> None: """Publish message to a MQTT topic.""" - hass.add_job(async_publish, hass, topic, payload, qos, retain, encoding) + hass.create_task(async_publish(hass, topic, payload, qos, retain, encoding)) async def async_publish( @@ -376,6 +376,7 @@ class MQTT: ) -> None: """Initialize Home Assistant MQTT client.""" self.hass = hass + self.loop = hass.loop self.config_entry = config_entry self.conf = conf @@ -806,7 +807,7 @@ class MQTT: self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage ) -> None: """Message received callback.""" - self.hass.add_job(self._mqtt_handle_message, msg) + self.loop.call_soon_threadsafe(self._mqtt_handle_message, msg) @lru_cache(None) # pylint: disable=method-cache-max-size-none def _matching_subscriptions(self, topic: str) -> list[Subscription]: From e465a4f8209a664bee244bc55ecce30ddf07cc4e Mon Sep 17 00:00:00 2001 From: Jc2k Date: Fri, 1 Sep 2023 23:33:19 +0100 Subject: [PATCH 048/984] Update bluetooth-data-tools to 1.11.0 (#99485) --- 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/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 59a87f4dfbb..54c8a52e24b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.1.1", "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", - "bluetooth-data-tools==1.9.1", + "bluetooth-data-tools==1.11.0", "dbus-fast==1.94.1" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5a4220464e7..bfb33c7b7d0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "async_interrupt==1.1.1", "aioesphomeapi==16.0.3", - "bluetooth-data-tools==1.9.1", + "bluetooth-data-tools==1.11.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 0c77e0e2ef5..798a80147de 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.9.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.11.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 36e3b7355ff..da5b4b0a4ee 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.9.1", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.11.0", "led-ble==1.0.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 19169de83f6..286bc927d45 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.1.1 bleak==0.20.2 bluetooth-adapters==0.16.0 bluetooth-auto-recovery==1.2.1 -bluetooth-data-tools==1.9.1 +bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index b6b801e0e69..f7355ea9483 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -549,7 +549,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.9.1 +bluetooth-data-tools==1.11.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e71ca77a059..3e3c6aab866 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -460,7 +460,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.9.1 +bluetooth-data-tools==1.11.0 # homeassistant.components.bond bond-async==0.2.1 From b681dc06e0a633a85b5d9dd8cf680e2a3ac8ee68 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 2 Sep 2023 09:47:59 +0200 Subject: [PATCH 049/984] Fix default language in Workday (#99463) Workday fix default language --- homeassistant/components/workday/binary_sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 6b6dfbffa5d..ad18c8863d6 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -129,7 +129,13 @@ async def async_setup_entry( workdays: list[str] = entry.options[CONF_WORKDAYS] year: int = (dt_util.now() + timedelta(days=days_offset)).year - obj_holidays: HolidayBase = country_holidays(country, subdiv=province, years=year) + cls: HolidayBase = country_holidays(country, subdiv=province, years=year) + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=year, + language=cls.default_language, + ) # Add custom holidays try: From 5fd14eade570eeaf004609d9ea996b1a9ef4a9f8 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Sat, 2 Sep 2023 01:20:36 -0700 Subject: [PATCH 050/984] Handle timestamp sensors in Prometheus integration (#98001) --- homeassistant/components/prometheus/__init__.py | 16 +++++++++++++++- tests/components/prometheus/test_init.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index e5d7f6cb060..adc5225b286 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.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, @@ -44,6 +45,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import as_timestamp from homeassistant.util.unit_conversion import TemperatureConverter _LOGGER = logging.getLogger(__name__) @@ -147,6 +149,7 @@ class PrometheusMetrics: self._sensor_metric_handlers = [ self._sensor_override_component_metric, self._sensor_override_metric, + self._sensor_timestamp_metric, self._sensor_attribute_metric, self._sensor_default_metric, self._sensor_fallback_metric, @@ -292,7 +295,10 @@ class PrometheusMetrics: def state_as_number(state): """Return a state casted to a float.""" try: - value = state_helper.state_as_number(state) + if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: + value = as_timestamp(state.state) + else: + value = state_helper.state_as_number(state) except ValueError: _LOGGER.debug("Could not convert %s to float", state) value = 0 @@ -576,6 +582,14 @@ class PrometheusMetrics: return f"sensor_{metric}_{unit}" return None + @staticmethod + def _sensor_timestamp_metric(state, unit): + """Get metric for timestamp sensors, which have no unit of measurement attribute.""" + metric = state.attributes.get(ATTR_DEVICE_CLASS) + if metric == SensorDeviceClass.TIMESTAMP: + return f"sensor_{metric}_seconds" + return None + def _sensor_override_metric(self, state, unit): """Get metric from override in configuration.""" if self._override_metric: diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 446666c4a6a..82a205eb259 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -232,6 +232,12 @@ async def test_sensor_device_class(client, sensor_entities) -> None: 'friendly_name="Radio Energy"} 14.0' in body ) + assert ( + 'sensor_timestamp_seconds{domain="sensor",' + 'entity="sensor.timestamp",' + 'friendly_name="Timestamp"} 1.691445808136036e+09' in body + ) + @pytest.mark.parametrize("namespace", [""]) async def test_input_number(client, input_number_entities) -> None: @@ -1049,6 +1055,16 @@ async def sensor_fixture( set_state_with_entry(hass, sensor_11, 50) data["sensor_11"] = sensor_11 + sensor_12 = entity_registry.async_get_or_create( + domain=sensor.DOMAIN, + platform="test", + unique_id="sensor_12", + original_device_class=SensorDeviceClass.TIMESTAMP, + suggested_object_id="Timestamp", + original_name="Timestamp", + ) + set_state_with_entry(hass, sensor_12, "2023-08-07T15:03:28.136036-0700") + data["sensor_12"] = sensor_12 await hass.async_block_till_done() return data From 1e46ecbb4823e4c0fcb5c60413fd6ce7b917675d Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Sat, 2 Sep 2023 10:55:12 +0200 Subject: [PATCH 051/984] Fix translation bug Renson sensors (#99461) * Fix translation bug * Revert "Fix translation bug" This reverts commit 84b5e90dac1e75a4c9f6d890865ac42044858682. * Fixed translation of Renson sensor --- homeassistant/components/renson/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index c8a355a0f7c..661ab82f373 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -266,6 +266,8 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( class RensonSensor(RensonEntity, SensorEntity): """Get a sensor data from the Renson API and store it in the state of the class.""" + _attr_has_entity_name = True + def __init__( self, description: RensonSensorEntityDescription, From 3d1efaa4ad7f7a63dfc614a8512b2679cb1f201d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Sep 2023 11:10:57 +0200 Subject: [PATCH 052/984] Freeze time for MQTT sensor expire tests (#99496) --- tests/components/mqtt/test_binary_sensor.py | 106 ++++++++++---------- tests/components/mqtt/test_sensor.py | 66 ++++++------ 2 files changed, 90 insertions(+), 82 deletions(-) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 28bf5f558cb..91a4833b1fc 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any from unittest.mock import patch +from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -146,57 +147,64 @@ async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + with freeze_time(now) as freezer: + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "ON") await hass.async_block_till_done() - # Value was set correctly. - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_ON + # Value was set correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_ON + # Value is not yet expired + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON - # Next message resets timer - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + # Next message resets timer + # Time jump 0.5s + now += timedelta(seconds=0.5) + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "OFF") await hass.async_block_till_done() - # Value was updated correctly. - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + # Value was updated correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + # Value is not yet expired + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF - # Time jump +2s - now = now + timedelta(seconds=2) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +2s + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is expired now - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_UNAVAILABLE + # Value is expired now + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_UNAVAILABLE async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test that binary_sensor with expire_after set behaves correctly on discovery and discovery update.""" await mqtt_mock_entry() @@ -212,31 +220,28 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( # Set time and publish config message to create binary_sensor via discovery with 4 s expiry realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - async_fire_mqtt_message( - hass, "homeassistant/binary_sensor/bla/config", config_msg - ) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", config_msg) + await hass.async_block_till_done() # Test that binary_sensor is not available state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE # Publish state message - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_mqtt_message(hass, "test-topic", "ON") - await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test-topic", "ON") + await hass.async_block_till_done() # Test that binary_sensor has correct state state = hass.states.get("binary_sensor.test") assert state.state == STATE_ON # Advance +3 seconds - now = now + timedelta(seconds=3) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # binary_sensor is not yet expired state = hass.states.get("binary_sensor.test") @@ -255,21 +260,18 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( assert state.state == STATE_ON # Add +2 seconds - now = now + timedelta(seconds=2) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Test that binary_sensor has expired state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE # Resend config message to update discovery - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_mqtt_message( - hass, "homeassistant/binary_sensor/bla/config", config_msg - ) - await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", config_msg) + await hass.async_block_till_done() # Test that binary_sensor is still expired state = hass.states.get("binary_sensor.test") diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 043c8d539b6..d9c92b315b3 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -360,51 +361,56 @@ async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + with freeze_time(now) as freezer: + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "100") await hass.async_block_till_done() - # Value was set correctly. - state = hass.states.get("sensor.test") - assert state.state == "100" + # Value was set correctly. + state = hass.states.get("sensor.test") + assert state.state == "100" - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("sensor.test") - assert state.state == "100" + # Value is not yet expired + state = hass.states.get("sensor.test") + assert state.state == "100" - # Next message resets timer - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + # Next message resets timer + now += timedelta(seconds=0.5) + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "101") await hass.async_block_till_done() - # Value was updated correctly. - state = hass.states.get("sensor.test") - assert state.state == "101" + # Value was updated correctly. + state = hass.states.get("sensor.test") + assert state.state == "101" - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("sensor.test") - assert state.state == "101" + # Value is not yet expired + state = hass.states.get("sensor.test") + assert state.state == "101" - # Time jump +2s - now = now + timedelta(seconds=2) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +2s + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is expired now - state = hass.states.get("sensor.test") - assert state.state == STATE_UNAVAILABLE + # Value is expired now + state = hass.states.get("sensor.test") + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize( From f48e8623da8443f72e617db5d22f919a28a8c063 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 2 Sep 2023 11:55:19 +0200 Subject: [PATCH 053/984] Use shorthand attributes in Hunterdouglas powerview (#99386) --- .../hunterdouglas_powerview/scene.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index ba1221a25ac..0c09917d35b 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -35,25 +35,14 @@ async def async_setup_entry( class PowerViewScene(HDEntity, Scene): """Representation of a Powerview scene.""" + _attr_icon = "mdi:blinds" + def __init__(self, coordinator, device_info, room_name, scene): """Initialize the scene.""" super().__init__(coordinator, device_info, room_name, scene.id) self._scene = scene - - @property - def name(self): - """Return the name of the scene.""" - return self._scene.name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} - - @property - def icon(self): - """Icon to use in the frontend.""" - return "mdi:blinds" + self._attr_name = scene.name + self._attr_extra_state_attributes = {STATE_ATTRIBUTE_ROOM_NAME: room_name} async def async_activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" From 4d3b978398818f4fe7a2094cb54f83c20a57ef18 Mon Sep 17 00:00:00 2001 From: Paarth Shah Date: Sat, 2 Sep 2023 06:02:55 -0700 Subject: [PATCH 054/984] Change matrix component to use matrix-nio instead of matrix_client (#72797) --- .coveragerc | 3 +- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/matrix/__init__.py | 533 ++++++++++-------- homeassistant/components/matrix/manifest.json | 4 +- homeassistant/components/matrix/notify.py | 13 +- mypy.ini | 10 + requirements_all.txt | 3 +- requirements_test.txt | 1 + requirements_test_all.txt | 4 + tests/components/matrix/__init__.py | 1 + tests/components/matrix/conftest.py | 248 ++++++++ tests/components/matrix/test_join_rooms.py | 22 + tests/components/matrix/test_login.py | 118 ++++ tests/components/matrix/test_matrix_bot.py | 88 +++ tests/components/matrix/test_send_message.py | 71 +++ 16 files changed, 879 insertions(+), 243 deletions(-) create mode 100644 tests/components/matrix/__init__.py create mode 100644 tests/components/matrix/conftest.py create mode 100644 tests/components/matrix/test_join_rooms.py create mode 100644 tests/components/matrix/test_login.py create mode 100644 tests/components/matrix/test_matrix_bot.py create mode 100644 tests/components/matrix/test_send_message.py diff --git a/.coveragerc b/.coveragerc index bf3dd5f4a00..d5a491a330f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -705,7 +705,8 @@ omit = homeassistant/components/mailgun/notify.py homeassistant/components/map/* homeassistant/components/mastodon/notify.py - homeassistant/components/matrix/* + homeassistant/components/matrix/__init__.py + homeassistant/components/matrix/notify.py homeassistant/components/matter/__init__.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py diff --git a/.strict-typing b/.strict-typing index e8bca0a1abd..3059c42f33f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -213,6 +213,7 @@ homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.mailbox.* homeassistant.components.mastodon.* +homeassistant.components.matrix.* homeassistant.components.matter.* homeassistant.components.media_extractor.* homeassistant.components.media_player.* diff --git a/CODEOWNERS b/CODEOWNERS index 65a36205518..bf6fdaf9fc5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -723,6 +723,8 @@ build.json @home-assistant/supervisor /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/mastodon/ @fabaff +/homeassistant/components/matrix/ @PaarthShah +/tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter /homeassistant/components/mazda/ @bdr99 diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index febafc367f1..cf7bcce7b3c 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -1,10 +1,28 @@ """The Matrix bot component.""" -from functools import partial +from __future__ import annotations + +import asyncio import logging import mimetypes import os +import re +from typing import NewType, TypedDict -from matrix_client.client import MatrixClient, MatrixRequestError +import aiofiles.os +from nio import AsyncClient, Event, MatrixRoom +from nio.events.room_events import RoomMessageText +from nio.responses import ( + ErrorResponse, + JoinError, + JoinResponse, + LoginError, + Response, + UploadError, + UploadResponse, + WhoamiError, + WhoamiResponse, +) +from PIL import Image import voluptuous as vol from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET @@ -16,8 +34,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType @@ -35,23 +53,37 @@ CONF_COMMANDS = "commands" CONF_WORD = "word" CONF_EXPRESSION = "expression" +EVENT_MATRIX_COMMAND = "matrix_command" + DEFAULT_CONTENT_TYPE = "application/octet-stream" MESSAGE_FORMATS = [FORMAT_HTML, FORMAT_TEXT] DEFAULT_MESSAGE_FORMAT = FORMAT_TEXT -EVENT_MATRIX_COMMAND = "matrix_command" - ATTR_FORMAT = "format" # optional message format ATTR_IMAGES = "images" # optional images +WordCommand = NewType("WordCommand", str) +ExpressionCommand = NewType("ExpressionCommand", re.Pattern) +RoomID = NewType("RoomID", str) + + +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 + + COMMAND_SCHEMA = vol.All( vol.Schema( { vol.Exclusive(CONF_WORD, "trigger"): cv.string, vol.Exclusive(CONF_EXPRESSION, "trigger"): cv.is_regex, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOMS): vol.All(cv.ensure_list, [cv.string]), } ), cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION), @@ -75,7 +107,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) - SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( { vol.Required(ATTR_MESSAGE): cv.string, @@ -90,30 +121,26 @@ SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Matrix bot component.""" config = config[DOMAIN] - try: - bot = MatrixBot( - hass, - os.path.join(hass.config.path(), SESSION_FILE), - config[CONF_HOMESERVER], - config[CONF_VERIFY_SSL], - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_ROOMS], - config[CONF_COMMANDS], - ) - hass.data[DOMAIN] = bot - except MatrixRequestError as exception: - _LOGGER.error("Matrix failed to log in: %s", str(exception)) - return False + matrix_bot = MatrixBot( + hass, + os.path.join(hass.config.path(), SESSION_FILE), + config[CONF_HOMESERVER], + config[CONF_VERIFY_SSL], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_ROOMS], + config[CONF_COMMANDS], + ) + hass.data[DOMAIN] = matrix_bot - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SEND_MESSAGE, - bot.handle_send_message, + matrix_bot.handle_send_message, schema=SERVICE_SCHEMA_SEND_MESSAGE, ) @@ -123,164 +150,141 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class MatrixBot: """The Matrix Bot.""" + _client: AsyncClient + def __init__( self, - hass, - config_file, - homeserver, - verify_ssl, - username, - password, - listening_rooms, - commands, - ): + hass: HomeAssistant, + config_file: str, + homeserver: str, + verify_ssl: bool, + username: str, + password: str, + listening_rooms: list[RoomID], + commands: list[ConfigCommand], + ) -> None: """Set up the client.""" self.hass = hass self._session_filepath = config_file - self._auth_tokens = self._get_auth_tokens() + self._access_tokens: JsonObjectType = {} self._homeserver = homeserver self._verify_tls = verify_ssl self._mx_id = username self._password = password + self._client = AsyncClient( + homeserver=self._homeserver, user=self._mx_id, ssl=self._verify_tls + ) + self._listening_rooms = listening_rooms - # We have to fetch the aliases for every room to make sure we don't - # join it twice by accident. However, fetching aliases is costly, - # so we only do it once per room. - self._aliases_fetched_for = set() + self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} + self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} + self._load_commands(commands) - # Word commands are stored dict-of-dict: First dict indexes by room ID - # / alias, second dict indexes by the word - self._word_commands = {} + async def stop_client(event: HassEvent) -> None: + """Run once when Home Assistant stops.""" + if self._client is not None: + await self._client.close() - # Regular expression commands are stored as a list of commands per - # room, i.e., a dict-of-list - self._expression_commands = {} + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) + async def handle_startup(event: HassEvent) -> None: + """Run once when Home Assistant finished startup.""" + self._access_tokens = await self._get_auth_tokens() + await self._login() + await self._join_rooms() + # Sync once so that we don't respond to past events. + await self._client.sync(timeout=30_000) + + self._client.add_event_callback(self._handle_room_message, RoomMessageText) + + await self._client.sync_forever( + timeout=30_000, + loop_sleep_time=1_000, + ) # milliseconds. + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, handle_startup) + + def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: - if not command.get(CONF_ROOMS): - command[CONF_ROOMS] = listening_rooms + # Set the command for all listening_rooms, unless otherwise specified. + command.setdefault(CONF_ROOMS, self._listening_rooms) # type: ignore[misc] - if command.get(CONF_WORD): - for room_id in command[CONF_ROOMS]: - if room_id not in self._word_commands: - self._word_commands[room_id] = {} - self._word_commands[room_id][command[CONF_WORD]] = command + # 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] + self._word_commands.setdefault(room_id, {}) + self._word_commands[room_id][word_command] = command # type: ignore[index] else: - for room_id in command[CONF_ROOMS]: - if room_id not in self._expression_commands: - self._expression_commands[room_id] = [] + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) - # Log in. This raises a MatrixRequestError if login is unsuccessful - self._client = self._login() - - def handle_matrix_exception(exception): - """Handle exceptions raised inside the Matrix SDK.""" - _LOGGER.error("Matrix exception:\n %s", str(exception)) - - self._client.start_listener_thread(exception_handler=handle_matrix_exception) - - def stop_client(_): - """Run once when Home Assistant stops.""" - self._client.stop_listener_thread() - - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) - - # Joining rooms potentially does a lot of I/O, so we defer it - def handle_startup(_): - """Run once when Home Assistant finished startup.""" - self._join_rooms() - - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, handle_startup) - - def _handle_room_message(self, room_id, room, event): + async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None: """Handle a message sent to a Matrix room.""" - if event["content"]["msgtype"] != "m.text": + # Corresponds to message type 'm.text' and NOT other RoomMessage subtypes, like 'm.notice' and 'm.emote'. + if not isinstance(message, RoomMessageText): return - - if event["sender"] == self._mx_id: + # Don't respond to our own messages. + if message.sender == self._mx_id: return + _LOGGER.debug("Handling message: %s", message.body) - _LOGGER.debug("Handling message: %s", event["content"]["body"]) + room_id = RoomID(room.room_id) - if event["content"]["body"][0] == "!": - # Could trigger a single-word command - pieces = event["content"]["body"].split(" ") - cmd = pieces[0][1:] + if message.body.startswith("!"): + # Could trigger a single-word command. + pieces = message.body.split() + word = WordCommand(pieces[0].lstrip("!")) - command = self._word_commands.get(room_id, {}).get(cmd) - if command: - event_data = { + if command := self._word_commands.get(room_id, {}).get(word): + message_data = { "command": command[CONF_NAME], - "sender": event["sender"], + "sender": message.sender, "room": room_id, "args": pieces[1:], } - self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - # After single-word commands, check all regex commands in the room + # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match = command[CONF_EXPRESSION].match(event["content"]["body"]) + match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] if not match: continue - event_data = { + message_data = { "command": command[CONF_NAME], - "sender": event["sender"], + "sender": message.sender, "room": room_id, "args": match.groupdict(), } - self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - def _join_or_get_room(self, room_id_or_alias): - """Join a room or get it, if we are already in the room. + async def _join_room(self, room_id_or_alias: str) -> None: + """Join a room or do nothing if already joined.""" + join_response = await self._client.join(room_id_or_alias) - We can't just always call join_room(), since that seems to crash - the client if we're already in the room. - """ - rooms = self._client.get_rooms() - if room_id_or_alias in rooms: - _LOGGER.debug("Already in room %s", room_id_or_alias) - return rooms[room_id_or_alias] + if isinstance(join_response, JoinResponse): + _LOGGER.debug("Joined or already in room '%s'", room_id_or_alias) + elif isinstance(join_response, JoinError): + _LOGGER.error( + "Could not join room '%s': %s", + room_id_or_alias, + join_response, + ) - for room in rooms.values(): - if room.room_id not in self._aliases_fetched_for: - room.update_aliases() - self._aliases_fetched_for.add(room.room_id) - - if ( - room_id_or_alias in room.aliases - or room_id_or_alias == room.canonical_alias - ): - _LOGGER.debug( - "Already in room %s (known as %s)", room.room_id, room_id_or_alias - ) - return room - - room = self._client.join_room(room_id_or_alias) - _LOGGER.info("Joined room %s (known as %s)", room.room_id, room_id_or_alias) - return room - - def _join_rooms(self): + async def _join_rooms(self) -> None: """Join the Matrix rooms that we listen for commands in.""" - for room_id in self._listening_rooms: - try: - room = self._join_or_get_room(room_id) - room.add_listener( - partial(self._handle_room_message, room_id), "m.room.message" - ) + rooms = [ + self.hass.async_create_task(self._join_room(room_id)) + for room_id in self._listening_rooms + ] + await asyncio.wait(rooms) - except MatrixRequestError as ex: - _LOGGER.error("Could not join room %s: %s", room_id, ex) - - def _get_auth_tokens(self) -> JsonObjectType: - """Read sorted authentication tokens from disk. - - Returns the auth_tokens dictionary. - """ + async def _get_auth_tokens(self) -> JsonObjectType: + """Read sorted authentication tokens from disk.""" try: return load_json_object(self._session_filepath) except HomeAssistantError as ex: @@ -291,116 +295,179 @@ class MatrixBot: ) return {} - def _store_auth_token(self, token): + async def _store_auth_token(self, token: str) -> None: """Store authentication token to session and persistent storage.""" - self._auth_tokens[self._mx_id] = token + self._access_tokens[self._mx_id] = token - save_json(self._session_filepath, self._auth_tokens) + await self.hass.async_add_executor_job( + save_json, self._session_filepath, self._access_tokens, True # private=True + ) - def _login(self): - """Login to the Matrix homeserver and return the client instance.""" - # Attempt to generate a valid client using either of the two possible - # login methods: - client = None + async def _login(self) -> None: + """Log in to the Matrix homeserver. - # If we have an authentication token - if self._mx_id in self._auth_tokens: - try: - client = self._login_by_token() - _LOGGER.debug("Logged in using stored token") + Attempts to use the stored access token. + If that fails, then tries using the password. + If that also fails, raises LocalProtocolError. + """ - except MatrixRequestError as ex: + # If we have an access token + if (token := self._access_tokens.get(self._mx_id)) is not None: + _LOGGER.debug("Restoring login from stored access token") + self._client.restore_login( + user_id=self._client.user_id, + device_id=self._client.device_id, + access_token=token, + ) + response = await self._client.whoami() + if isinstance(response, WhoamiError): _LOGGER.warning( - "Login by token failed, falling back to password: %d, %s", - ex.code, - ex.content, + "Restoring login from access token failed: %s, %s", + response.status_code, + response.message, + ) + self._client.access_token = ( + "" # Force a soft-logout if the homeserver didn't. + ) + elif isinstance(response, WhoamiResponse): + _LOGGER.debug( + "Successfully restored login from access token: user_id '%s', device_id '%s'", + response.user_id, + response.device_id, ) - # If we still don't have a client try password - if not client: - try: - client = self._login_by_password() - _LOGGER.debug("Logged in using password") + # If the token login did not succeed + if not self._client.logged_in: + response = await self._client.login(password=self._password) + _LOGGER.debug("Logging in using password") - except MatrixRequestError as ex: - _LOGGER.error( - "Login failed, both token and username/password invalid: %d, %s", - ex.code, - ex.content, + if isinstance(response, LoginError): + _LOGGER.warning( + "Login by password failed: %s, %s", + response.status_code, + response.message, ) - # Re-raise the error so _setup can catch it - raise - return client + if not self._client.logged_in: + raise ConfigEntryAuthFailed( + "Login failed, both token and username/password are invalid" + ) - def _login_by_token(self): - """Login using authentication token and return the client.""" - return MatrixClient( - base_url=self._homeserver, - token=self._auth_tokens[self._mx_id], - user_id=self._mx_id, - valid_cert_check=self._verify_tls, + await self._store_auth_token(self._client.access_token) + + async def _handle_room_send( + self, target_room: RoomID, message_type: str, content: dict + ) -> None: + """Wrap _client.room_send and handle ErrorResponses.""" + response: Response = await self._client.room_send( + room_id=target_room, + message_type=message_type, + content=content, ) + if isinstance(response, ErrorResponse): + _LOGGER.error( + "Unable to deliver message to room '%s': %s", + target_room, + response, + ) + else: + _LOGGER.debug("Message delivered to room '%s'", target_room) - def _login_by_password(self): - """Login using password authentication and return the client.""" - _client = MatrixClient( - base_url=self._homeserver, valid_cert_check=self._verify_tls + async def _handle_multi_room_send( + self, target_rooms: list[RoomID], message_type: str, content: dict + ) -> None: + """Wrap _handle_room_send for multiple target_rooms.""" + _tasks = [] + for target_room in target_rooms: + _tasks.append( + self.hass.async_create_task( + self._handle_room_send( + target_room=target_room, + message_type=message_type, + content=content, + ) + ) + ) + await asyncio.wait(_tasks) + + async def _send_image(self, image_path: str, target_rooms: list[RoomID]) -> None: + """Upload an image, then send it to all target_rooms.""" + _is_allowed_path = await self.hass.async_add_executor_job( + self.hass.config.is_allowed_path, image_path ) - - _client.login_with_password(self._mx_id, self._password) - - self._store_auth_token(_client.token) - - return _client - - def _send_image(self, img, target_rooms): - _LOGGER.debug("Uploading file from path, %s", img) - - if not self.hass.config.is_allowed_path(img): - _LOGGER.error("Path not allowed: %s", img) + if not _is_allowed_path: + _LOGGER.error("Path not allowed: %s", image_path) return - with open(img, "rb") as upfile: - imgfile = upfile.read() - content_type = mimetypes.guess_type(img)[0] - mxc = self._client.upload(imgfile, content_type) - for target_room in target_rooms: - try: - room = self._join_or_get_room(target_room) - room.send_image(mxc, img, mimetype=content_type) - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': %d, %s", - target_room, - ex.code, - ex.content, - ) - def _send_message(self, message, data, target_rooms): - """Send the message to the Matrix server.""" - for target_room in target_rooms: - try: - room = self._join_or_get_room(target_room) - if message is not None: - if data.get(ATTR_FORMAT) == FORMAT_HTML: - _LOGGER.debug(room.send_html(message)) - else: - _LOGGER.debug(room.send_text(message)) - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': %d, %s", - target_room, - ex.code, - ex.content, - ) - if ATTR_IMAGES in data: - for img in data.get(ATTR_IMAGES, []): - self._send_image(img, target_rooms) + # Get required image metadata. + image = await self.hass.async_add_executor_job(Image.open, image_path) + (width, height) = image.size + mime_type = mimetypes.guess_type(image_path)[0] + file_stat = await aiofiles.os.stat(image_path) - def handle_send_message(self, service: ServiceCall) -> None: - """Handle the send_message service.""" - self._send_message( - service.data.get(ATTR_MESSAGE), - service.data.get(ATTR_DATA), - service.data[ATTR_TARGET], + _LOGGER.debug("Uploading file from path, %s", image_path) + async with aiofiles.open(image_path, "r+b") as image_file: + response, _ = await self._client.upload( + image_file, + content_type=mime_type, + filename=os.path.basename(image_path), + filesize=file_stat.st_size, + ) + if isinstance(response, UploadError): + _LOGGER.error("Unable to upload image to the homeserver: %s", response) + return + if isinstance(response, UploadResponse): + _LOGGER.debug("Successfully uploaded image to the homeserver") + else: + _LOGGER.error( + "Unknown response received when uploading image to homeserver: %s", + response, + ) + return + + content = { + "body": os.path.basename(image_path), + "info": { + "size": file_stat.st_size, + "mimetype": mime_type, + "w": width, + "h": height, + }, + "msgtype": "m.image", + "url": response.content_uri, + } + + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) + + async def _send_message( + self, message: str, target_rooms: list[RoomID], data: dict | None + ) -> None: + """Send a message to the Matrix server.""" + content = {"msgtype": "m.text", "body": message} + if data is not None and data.get(ATTR_FORMAT) == FORMAT_HTML: + content |= {"format": "org.matrix.custom.html", "formatted_body": message} + + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) + + if ( + data is not None + and (image_paths := data.get(ATTR_IMAGES, [])) + and len(target_rooms) > 0 + ): + image_tasks = [ + self.hass.async_create_task(self._send_image(image_path, target_rooms)) + for image_path in image_paths + ] + await asyncio.wait(image_tasks) + + async def handle_send_message(self, service: ServiceCall) -> None: + """Handle the send_message service.""" + await self._send_message( + service.data[ATTR_MESSAGE], + service.data[ATTR_TARGET], + service.data.get(ATTR_DATA), ) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 4bded80a711..74bb97d10fc 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -1,9 +1,9 @@ { "domain": "matrix", "name": "Matrix", - "codeowners": [], + "codeowners": ["@PaarthShah"], "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-client==0.4.0"] + "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.0"] } diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index 3c90e9afbc0..c71f91eb582 100644 --- a/homeassistant/components/matrix/notify.py +++ b/homeassistant/components/matrix/notify.py @@ -1,6 +1,8 @@ """Support for Matrix notifications.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.notify import ( @@ -14,6 +16,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import RoomID from .const import DOMAIN, SERVICE_SEND_MESSAGE CONF_DEFAULT_ROOM = "default_room" @@ -33,16 +36,14 @@ def get_service( class MatrixNotificationService(BaseNotificationService): """Send notifications to a Matrix room.""" - def __init__(self, default_room): + def __init__(self, default_room: RoomID) -> None: """Set up the Matrix notification service.""" self._default_room = default_room - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send the message to the Matrix server.""" - target_rooms = kwargs.get(ATTR_TARGET) or [self._default_room] + target_rooms: list[RoomID] = kwargs.get(ATTR_TARGET) or [self._default_room] service_data = {ATTR_TARGET: target_rooms, ATTR_MESSAGE: message} if (data := kwargs.get(ATTR_DATA)) is not None: service_data[ATTR_DATA] = data - return self.hass.services.call( - DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data - ) + self.hass.services.call(DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data) diff --git a/mypy.ini b/mypy.ini index 82cce328c6a..9802c26c3c6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1892,6 +1892,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.matrix.*] +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.matter.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index f7355ea9483..4c5497ae98c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -37,6 +37,7 @@ Mastodon.py==1.5.1 # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload +# homeassistant.components.matrix # homeassistant.components.proxy # homeassistant.components.qrcode # homeassistant.components.seven_segments @@ -1177,7 +1178,7 @@ lxml==4.9.3 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-client==0.4.0 +matrix-nio==0.21.2 # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index a2533d0ef2b..89db04a5db8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -33,6 +33,7 @@ requests_mock==1.11.0 respx==0.20.2 syrupy==4.2.1 tqdm==4.66.1 +types-aiofiles==22.1.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e3c6aab866..18e4f21914e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,6 +33,7 @@ HATasmota==0.7.0 # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload +# homeassistant.components.matrix # homeassistant.components.proxy # homeassistant.components.qrcode # homeassistant.components.seven_segments @@ -899,6 +900,9 @@ lxml==4.9.3 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 +# homeassistant.components.matrix +matrix-nio==0.21.2 + # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/tests/components/matrix/__init__.py b/tests/components/matrix/__init__.py new file mode 100644 index 00000000000..a520f7e7c23 --- /dev/null +++ b/tests/components/matrix/__init__.py @@ -0,0 +1 @@ +"""Tests for the Matrix component.""" diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py new file mode 100644 index 00000000000..d0970b96019 --- /dev/null +++ b/tests/components/matrix/conftest.py @@ -0,0 +1,248 @@ +"""Define fixtures available for all tests.""" +from __future__ import annotations + +import re +import tempfile +from unittest.mock import patch + +from nio import ( + AsyncClient, + ErrorResponse, + JoinError, + JoinResponse, + LocalProtocolError, + LoginError, + LoginResponse, + Response, + UploadResponse, + WhoamiError, + WhoamiResponse, +) +from PIL import Image +import pytest + +from homeassistant.components.matrix import ( + CONF_COMMANDS, + CONF_EXPRESSION, + CONF_HOMESERVER, + CONF_ROOMS, + CONF_WORD, + EVENT_MATRIX_COMMAND, + MatrixBot, + RoomID, +) +from homeassistant.components.matrix.const import DOMAIN as MATRIX_DOMAIN +from homeassistant.components.matrix.notify import CONF_DEFAULT_ROOM +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +TEST_NOTIFIER_NAME = "matrix_notify" + +TEST_DEFAULT_ROOM = "!DefaultNotificationRoom:example.com" +TEST_JOINABLE_ROOMS = ["!RoomIdString:example.com", "#RoomAliasString:example.com"] +TEST_BAD_ROOM = "!UninvitedRoom:example.com" +TEST_MXID = "@user:example.com" +TEST_DEVICE_ID = "FAKEID" +TEST_PASSWORD = "password" +TEST_TOKEN = "access_token" + +NIO_IMPORT_PREFIX = "homeassistant.components.matrix.nio." + + +class _MockAsyncClient(AsyncClient): + """Mock class to simulate MatrixBot._client's I/O methods.""" + + async def close(self): + return None + + async def join(self, room_id: RoomID): + if room_id in TEST_JOINABLE_ROOMS: + return JoinResponse(room_id=room_id) + else: + return JoinError(message="Not allowed to join this room.") + + async def login(self, *args, **kwargs): + if kwargs.get("password") == TEST_PASSWORD or kwargs.get("token") == TEST_TOKEN: + self.access_token = TEST_TOKEN + return LoginResponse( + access_token=TEST_TOKEN, + device_id="test_device", + user_id=TEST_MXID, + ) + else: + self.access_token = "" + return LoginError(message="LoginError", status_code="status_code") + + async def logout(self, *args, **kwargs): + self.access_token = "" + + async def whoami(self): + if self.access_token == TEST_TOKEN: + self.user_id = TEST_MXID + self.device_id = TEST_DEVICE_ID + return WhoamiResponse( + user_id=TEST_MXID, device_id=TEST_DEVICE_ID, is_guest=False + ) + else: + self.access_token = "" + return WhoamiError( + message="Invalid access token passed.", status_code="M_UNKNOWN_TOKEN" + ) + + async def room_send(self, *args, **kwargs): + if not self.logged_in: + raise LocalProtocolError + if kwargs["room_id"] in TEST_JOINABLE_ROOMS: + return Response() + else: + return ErrorResponse(message="Cannot send a message in this room.") + + async def sync(self, *args, **kwargs): + return None + + async def sync_forever(self, *args, **kwargs): + return None + + async def upload(self, *args, **kwargs): + return UploadResponse(content_uri="mxc://example.com/randomgibberish"), None + + +MOCK_CONFIG_DATA = { + MATRIX_DOMAIN: { + CONF_HOMESERVER: "https://matrix.example.com", + CONF_USERNAME: TEST_MXID, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: True, + CONF_ROOMS: TEST_JOINABLE_ROOMS, + CONF_COMMANDS: [ + { + CONF_WORD: "WordTrigger", + CONF_NAME: "WordTriggerEventName", + }, + { + CONF_EXPRESSION: "My name is (?P.*)", + CONF_NAME: "ExpressionTriggerEventName", + }, + ], + }, + NOTIFY_DOMAIN: { + CONF_NAME: TEST_NOTIFIER_NAME, + CONF_PLATFORM: MATRIX_DOMAIN, + CONF_DEFAULT_ROOM: TEST_DEFAULT_ROOM, + }, +} + +MOCK_WORD_COMMANDS = { + "!RoomIdString:example.com": { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + }, + "#RoomAliasString:example.com": { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + }, +} + +MOCK_EXPRESSION_COMMANDS = { + "!RoomIdString:example.com": [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + ], + "#RoomAliasString:example.com": [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + ], +} + + +@pytest.fixture +def mock_client(): + """Return mocked AsyncClient.""" + with patch("homeassistant.components.matrix.AsyncClient", _MockAsyncClient) as mock: + yield mock + + +@pytest.fixture +def mock_save_json(): + """Prevent saving test access_tokens.""" + with patch("homeassistant.components.matrix.save_json") as mock: + yield mock + + +@pytest.fixture +def mock_load_json(): + """Mock loading access_tokens from a file.""" + with patch( + "homeassistant.components.matrix.load_json_object", + return_value={TEST_MXID: TEST_TOKEN}, + ) as mock: + yield mock + + +@pytest.fixture +def mock_allowed_path(): + """Allow using NamedTemporaryFile for mock image.""" + with patch("homeassistant.core.Config.is_allowed_path", return_value=True) as mock: + yield mock + + +@pytest.fixture +async def matrix_bot( + hass: HomeAssistant, mock_client, mock_save_json, mock_allowed_path +) -> MatrixBot: + """Set up Matrix and Notify component. + + The resulting MatrixBot will have a mocked _client. + """ + + assert await async_setup_component(hass, MATRIX_DOMAIN, MOCK_CONFIG_DATA) + assert await async_setup_component(hass, NOTIFY_DOMAIN, MOCK_CONFIG_DATA) + await hass.async_block_till_done() + assert isinstance(matrix_bot := hass.data[MATRIX_DOMAIN], MatrixBot) + + await hass.async_start() + + return matrix_bot + + +@pytest.fixture +def matrix_events(hass: HomeAssistant): + """Track event calls.""" + return async_capture_events(hass, MATRIX_DOMAIN) + + +@pytest.fixture +def command_events(hass: HomeAssistant): + """Track event calls.""" + return async_capture_events(hass, EVENT_MATRIX_COMMAND) + + +@pytest.fixture +def image_path(tmp_path): + """Provide the Path to a mock image.""" + image = Image.new("RGBA", size=(50, 50), color=(256, 0, 0)) + image_file = tempfile.NamedTemporaryFile(dir=tmp_path) + image.save(image_file, "PNG") + return image_file diff --git a/tests/components/matrix/test_join_rooms.py b/tests/components/matrix/test_join_rooms.py new file mode 100644 index 00000000000..54856b91ac3 --- /dev/null +++ b/tests/components/matrix/test_join_rooms.py @@ -0,0 +1,22 @@ +"""Test MatrixBot._join.""" + +from homeassistant.components.matrix import MatrixBot + +from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS + + +async def test_join(matrix_bot: MatrixBot, caplog): + """Test joining configured rooms.""" + + # Join configured rooms. + await matrix_bot._join_rooms() + for room_id in TEST_JOINABLE_ROOMS: + assert f"Joined or already in room '{room_id}'" in caplog.messages + + # Joining a disallowed room should not raise an exception. + matrix_bot._listening_rooms = [TEST_BAD_ROOM] + await matrix_bot._join_rooms() + assert ( + f"Could not join room '{TEST_BAD_ROOM}': JoinError: Not allowed to join this room." + in caplog.messages + ) diff --git a/tests/components/matrix/test_login.py b/tests/components/matrix/test_login.py new file mode 100644 index 00000000000..8112d98fc8c --- /dev/null +++ b/tests/components/matrix/test_login.py @@ -0,0 +1,118 @@ +"""Test MatrixBot._login.""" + +from pydantic.dataclasses import dataclass +import pytest + +from homeassistant.components.matrix import MatrixBot +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError + +from tests.components.matrix.conftest import ( + TEST_DEVICE_ID, + TEST_MXID, + TEST_PASSWORD, + TEST_TOKEN, +) + + +@dataclass +class LoginTestParameters: + """Dataclass of parameters representing the login parameters and expected result state.""" + + password: str + access_token: dict[str, str] + expected_login_state: bool + expected_caplog_messages: set[str] + expected_expection: type(Exception) | None = None + + +good_password_missing_token = LoginTestParameters( + password=TEST_PASSWORD, + access_token={}, + expected_login_state=True, + expected_caplog_messages={"Logging in using password"}, +) + +good_password_bad_token = LoginTestParameters( + password=TEST_PASSWORD, + access_token={TEST_MXID: "WrongToken"}, + expected_login_state=True, + expected_caplog_messages={ + "Restoring login from stored access token", + "Restoring login from access token failed: M_UNKNOWN_TOKEN, Invalid access token passed.", + "Logging in using password", + }, +) + +bad_password_good_access_token = LoginTestParameters( + password="WrongPassword", + access_token={TEST_MXID: TEST_TOKEN}, + expected_login_state=True, + expected_caplog_messages={ + "Restoring login from stored access token", + f"Successfully restored login from access token: user_id '{TEST_MXID}', device_id '{TEST_DEVICE_ID}'", + }, +) + +bad_password_bad_access_token = LoginTestParameters( + password="WrongPassword", + access_token={TEST_MXID: "WrongToken"}, + expected_login_state=False, + expected_caplog_messages={ + "Restoring login from stored access token", + "Restoring login from access token failed: M_UNKNOWN_TOKEN, Invalid access token passed.", + "Logging in using password", + "Login by password failed: status_code, LoginError", + }, + expected_expection=ConfigEntryAuthFailed, +) + +bad_password_missing_access_token = LoginTestParameters( + password="WrongPassword", + access_token={}, + expected_login_state=False, + expected_caplog_messages={ + "Logging in using password", + "Login by password failed: status_code, LoginError", + }, + expected_expection=ConfigEntryAuthFailed, +) + + +@pytest.mark.parametrize( + "params", + [ + good_password_missing_token, + good_password_bad_token, + bad_password_good_access_token, + bad_password_bad_access_token, + bad_password_missing_access_token, + ], +) +async def test_login( + matrix_bot: MatrixBot, caplog: pytest.LogCaptureFixture, params: LoginTestParameters +): + """Test logging in with the given parameters and expected state.""" + await matrix_bot._client.logout() + matrix_bot._password = params.password + matrix_bot._access_tokens = params.access_token + + if params.expected_expection: + with pytest.raises(params.expected_expection): + await matrix_bot._login() + else: + await matrix_bot._login() + assert matrix_bot._client.logged_in == params.expected_login_state + assert set(caplog.messages).issuperset(params.expected_caplog_messages) + + +async def test_get_auth_tokens(matrix_bot: MatrixBot, mock_load_json): + """Test loading access_tokens from a mocked file.""" + + # Test loading good tokens. + loaded_tokens = await matrix_bot._get_auth_tokens() + assert loaded_tokens == {TEST_MXID: TEST_TOKEN} + + # Test miscellaneous error from hass. + mock_load_json.side_effect = HomeAssistantError() + loaded_tokens = await matrix_bot._get_auth_tokens() + assert loaded_tokens == {} diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py new file mode 100644 index 00000000000..0b150a629fe --- /dev/null +++ b/tests/components/matrix/test_matrix_bot.py @@ -0,0 +1,88 @@ +"""Configure and test MatrixBot.""" +from nio import MatrixRoom, RoomMessageText + +from homeassistant.components.matrix import ( + DOMAIN as MATRIX_DOMAIN, + SERVICE_SEND_MESSAGE, + MatrixBot, +) +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import ( + MOCK_EXPRESSION_COMMANDS, + MOCK_WORD_COMMANDS, + TEST_JOINABLE_ROOMS, + TEST_NOTIFIER_NAME, +) + + +async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): + """Test hass/MatrixBot state.""" + + services = hass.services.async_services() + + # Verify that the matrix service is registered + assert (matrix_service := services.get(MATRIX_DOMAIN)) + assert SERVICE_SEND_MESSAGE in matrix_service + + # Verify that the matrix notifier is registered + assert (notify_service := services.get(NOTIFY_DOMAIN)) + assert TEST_NOTIFIER_NAME in notify_service + + +async def test_commands(hass, matrix_bot: MatrixBot, command_events): + """Test that the configured commands were parsed correctly.""" + + assert len(command_events) == 0 + + assert matrix_bot._word_commands == MOCK_WORD_COMMANDS + assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS + + room_id = TEST_JOINABLE_ROOMS[0] + room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) + + # Test single-word command. + word_command_message = RoomMessageText( + body="!WordTrigger arg1 arg2", + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, + ) + await matrix_bot._handle_room_message(room, word_command_message) + await hass.async_block_till_done() + assert len(command_events) == 1 + event = command_events.pop() + assert event.data == { + "command": "WordTriggerEventName", + "sender": "@SomeUser:example.com", + "room": room_id, + "args": ["arg1", "arg2"], + } + + # Test expression command. + room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) + expression_command_message = RoomMessageText( + body="My name is FakeName", + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, + ) + await matrix_bot._handle_room_message(room, expression_command_message) + await hass.async_block_till_done() + assert len(command_events) == 1 + event = command_events.pop() + assert event.data == { + "command": "ExpressionTriggerEventName", + "sender": "@SomeUser:example.com", + "room": room_id, + "args": {"name": "FakeName"}, + } diff --git a/tests/components/matrix/test_send_message.py b/tests/components/matrix/test_send_message.py new file mode 100644 index 00000000000..34964f2b091 --- /dev/null +++ b/tests/components/matrix/test_send_message.py @@ -0,0 +1,71 @@ +"""Test the send_message service.""" + +from homeassistant.components.matrix import ( + ATTR_FORMAT, + ATTR_IMAGES, + DOMAIN as MATRIX_DOMAIN, + MatrixBot, +) +from homeassistant.components.matrix.const import FORMAT_HTML, SERVICE_SEND_MESSAGE +from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET +from homeassistant.core import HomeAssistant + +from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS + + +async def test_send_message( + hass: HomeAssistant, matrix_bot: MatrixBot, image_path, matrix_events, caplog +): + """Test the send_message service.""" + assert len(matrix_events) == 0 + await matrix_bot._login() + + # Send a message without an attached image. + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_JOINABLE_ROOMS} + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + # Send an HTML message without an attached image. + data = { + ATTR_MESSAGE: "Test message", + ATTR_TARGET: TEST_JOINABLE_ROOMS, + ATTR_DATA: {ATTR_FORMAT: FORMAT_HTML}, + } + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + # Send a message with an attached image. + data[ATTR_DATA] = {ATTR_IMAGES: [image_path.name]} + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + for room_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_id}'" in caplog.messages + + +async def test_unsendable_message( + hass: HomeAssistant, matrix_bot: MatrixBot, matrix_events, caplog +): + """Test the send_message service with an invalid room.""" + assert len(matrix_events) == 0 + await matrix_bot._login() + + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_BAD_ROOM} + + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + assert ( + f"Unable to deliver message to room '{TEST_BAD_ROOM}': ErrorResponse: Cannot send a message in this room." + in caplog.messages + ) From d88ee0dbe0253ec5e8895af1836bd543ec2e2c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 2 Sep 2023 15:08:49 +0200 Subject: [PATCH 055/984] Update Tibber library to 0.28.2 (#99115) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index c668430914f..1d8120a4321 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.28.0"] + "requirements": ["pyTibber==0.28.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c5497ae98c..d72b41f442e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1548,7 +1548,7 @@ pyRFXtrx==0.30.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.28.0 +pyTibber==0.28.2 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18e4f21914e..3049cb33268 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1163,7 +1163,7 @@ pyElectra==1.2.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.28.0 +pyTibber==0.28.2 # homeassistant.components.dlink pyW215==0.7.0 From 26b1222faedd72be7c6f0f53209976ca7a34f978 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sat, 2 Sep 2023 15:21:05 +0100 Subject: [PATCH 056/984] Support tracking private bluetooth devices (#99465) Co-authored-by: J. Nick Koston --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/private_ble_device/__init__.py | 19 ++ .../private_ble_device/config_flow.py | 60 +++++ .../components/private_ble_device/const.py | 2 + .../private_ble_device/coordinator.py | 236 ++++++++++++++++++ .../private_ble_device/device_tracker.py | 75 ++++++ .../components/private_ble_device/entity.py | 71 ++++++ .../private_ble_device/manifest.json | 10 + .../private_ble_device/strings.json | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../components/private_ble_device/__init__.py | 78 ++++++ .../components/private_ble_device/conftest.py | 1 + .../private_ble_device/test_config_flow.py | 132 ++++++++++ .../private_ble_device/test_device_tracker.py | 183 ++++++++++++++ 19 files changed, 909 insertions(+) create mode 100644 homeassistant/components/private_ble_device/__init__.py create mode 100644 homeassistant/components/private_ble_device/config_flow.py create mode 100644 homeassistant/components/private_ble_device/const.py create mode 100644 homeassistant/components/private_ble_device/coordinator.py create mode 100644 homeassistant/components/private_ble_device/device_tracker.py create mode 100644 homeassistant/components/private_ble_device/entity.py create mode 100644 homeassistant/components/private_ble_device/manifest.json create mode 100644 homeassistant/components/private_ble_device/strings.json create mode 100644 tests/components/private_ble_device/__init__.py create mode 100644 tests/components/private_ble_device/conftest.py create mode 100644 tests/components/private_ble_device/test_config_flow.py create mode 100644 tests/components/private_ble_device/test_device_tracker.py diff --git a/.strict-typing b/.strict-typing index 3059c42f33f..2a6e9b04cbe 100644 --- a/.strict-typing +++ b/.strict-typing @@ -255,6 +255,7 @@ homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.powerwall.* +homeassistant.components.private_ble_device.* homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* diff --git a/CODEOWNERS b/CODEOWNERS index bf6fdaf9fc5..b937c2769fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -951,6 +951,8 @@ build.json @home-assistant/supervisor /tests/components/poolsense/ @haemishkyd /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson +/homeassistant/components/private_ble_device/ @Jc2k +/tests/components/private_ble_device/ @Jc2k /homeassistant/components/profiler/ @bdraco /tests/components/profiler/ @bdraco /homeassistant/components/progettihwsw/ @ardaseremet diff --git a/homeassistant/components/private_ble_device/__init__.py b/homeassistant/components/private_ble_device/__init__.py new file mode 100644 index 00000000000..c4666ccc02f --- /dev/null +++ b/homeassistant/components/private_ble_device/__init__.py @@ -0,0 +1,19 @@ +"""Private BLE Device integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tracking of a private bluetooth device 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 entities for a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py new file mode 100644 index 00000000000..5bf130a0396 --- /dev/null +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for the BLE Tracker.""" +from __future__ import annotations + +import base64 +import binascii +import logging + +import voluptuous as vol + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN +from .coordinator import async_last_service_info + +_LOGGER = logging.getLogger(__name__) + +CONF_IRK = "irk" + + +class BLEDeviceTrackerConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BLE Device Tracker.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Set up by user.""" + errors: dict[str, str] = {} + + if not bluetooth.async_scanner_count(self.hass, connectable=False): + return self.async_abort(reason="bluetooth_not_available") + + if user_input is not None: + irk = user_input[CONF_IRK] + if irk.startswith("irk:"): + irk = irk[4:] + + if irk.endswith("="): + irk_bytes = bytes(reversed(base64.b64decode(irk))) + else: + irk_bytes = binascii.unhexlify(irk) + + if len(irk_bytes) != 16: + errors[CONF_IRK] = "irk_not_valid" + elif not (service_info := async_last_service_info(self.hass, irk_bytes)): + errors[CONF_IRK] = "irk_not_found" + else: + await self.async_set_unique_id(irk_bytes.hex()) + return self.async_create_entry( + title=service_info.name or "BLE Device Tracker", + data={CONF_IRK: irk_bytes.hex()}, + ) + + data_schema = vol.Schema({CONF_IRK: str}) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/private_ble_device/const.py b/homeassistant/components/private_ble_device/const.py new file mode 100644 index 00000000000..086fd06bfd5 --- /dev/null +++ b/homeassistant/components/private_ble_device/const.py @@ -0,0 +1,2 @@ +"""Constants for Private BLE Device.""" +DOMAIN = "private_ble_device" diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py new file mode 100644 index 00000000000..863b2833851 --- /dev/null +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -0,0 +1,236 @@ +"""Central manager for tracking devices with random but resolvable MAC addresses.""" +from __future__ import annotations + +from collections.abc import Callable +import logging +from typing import cast + +from bluetooth_data_tools import get_cipher_for_irk, resolve_private_address +from cryptography.hazmat.primitives.ciphers import Cipher + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UnavailableCallback = Callable[[bluetooth.BluetoothServiceInfoBleak], None] +Cancellable = Callable[[], None] + + +def async_last_service_info( + hass: HomeAssistant, irk: bytes +) -> bluetooth.BluetoothServiceInfoBleak | None: + """Find a BluetoothServiceInfoBleak for the irk. + + This iterates over all currently visible mac addresses and checks them against `irk`. + It returns the newest. + """ + + # This can't use existing data collected by the coordinator - its called when + # the coordinator doesn't know about the IRK, so doesn't optimise this lookup. + + cur: bluetooth.BluetoothServiceInfoBleak | None = None + cipher = get_cipher_for_irk(irk) + + for service_info in bluetooth.async_discovered_service_info(hass, False): + if resolve_private_address(cipher, service_info.address): + if not cur or cur.time < service_info.time: + cur = service_info + + return cur + + +class PrivateDevicesCoordinator: + """Monitor private bluetooth devices and correlate them with known IRK. + + This class should not be instanced directly - use `async_get_coordinator` to get an instance. + + There is a single shared coordinator for all instances of this integration. This is to avoid + unnecessary hashing (AES) operations as much as possible. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the manager.""" + self.hass = hass + + self._irks: dict[bytes, Cipher] = {} + self._unavailable_callbacks: dict[bytes, list[UnavailableCallback]] = {} + self._service_info_callbacks: dict[ + bytes, list[bluetooth.BluetoothCallback] + ] = {} + + self._mac_to_irk: dict[str, bytes] = {} + self._irk_to_mac: dict[bytes, str] = {} + + # These MAC addresses have been compared to the IRK list + # They are unknown, so we can ignore them. + self._ignored: dict[str, Cancellable] = {} + + self._unavailability_trackers: dict[bytes, Cancellable] = {} + self._listener_cancel: Cancellable | None = None + + def _async_ensure_started(self) -> None: + if not self._listener_cancel: + self._listener_cancel = bluetooth.async_register_callback( + self.hass, + self._async_track_service_info, + BluetoothCallbackMatcher(connectable=False), + bluetooth.BluetoothScanningMode.ACTIVE, + ) + + def _async_ensure_stopped(self) -> None: + if self._listener_cancel: + self._listener_cancel() + self._listener_cancel = None + + for cancel in self._ignored.values(): + cancel() + self._ignored.clear() + + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + # This should be called when the current MAC address associated with an IRK goes away. + if resolved := self._mac_to_irk.get(service_info.address): + if callbacks := self._unavailable_callbacks.get(resolved): + for cb in callbacks: + cb(service_info) + return + + def _async_irk_resolved_to_mac(self, irk: bytes, mac: str) -> None: + if previous_mac := self._irk_to_mac.get(irk): + self._mac_to_irk.pop(previous_mac, None) + + self._mac_to_irk[mac] = irk + self._irk_to_mac[irk] = mac + + # Stop ignoring this MAC + self._ignored.pop(mac, None) + + # Ignore availability events for the previous address + if cancel := self._unavailability_trackers.pop(irk, None): + cancel() + + # Track available for new address + self._unavailability_trackers[irk] = bluetooth.async_track_unavailable( + self.hass, self._async_track_unavailable, mac, False + ) + + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + mac = service_info.address + + if mac in self._ignored: + return + + if resolved := self._mac_to_irk.get(mac): + if callbacks := self._service_info_callbacks.get(resolved): + for cb in callbacks: + cb(service_info, change) + return + + for irk, cipher in self._irks.items(): + if resolve_private_address(cipher, service_info.address): + self._async_irk_resolved_to_mac(irk, mac) + if callbacks := self._service_info_callbacks.get(irk): + for cb in callbacks: + cb(service_info, change) + return + + def _unignore(service_info: bluetooth.BluetoothServiceInfoBleak) -> None: + self._ignored.pop(service_info.address, None) + + self._ignored[mac] = bluetooth.async_track_unavailable( + self.hass, _unignore, mac, False + ) + + def _async_maybe_learn_irk(self, irk: bytes) -> None: + """Add irk to list of irks that we can use to resolve RPAs.""" + if irk not in self._irks: + if service_info := async_last_service_info(self.hass, irk): + self._async_irk_resolved_to_mac(irk, service_info.address) + self._irks[irk] = get_cipher_for_irk(irk) + + def _async_maybe_forget_irk(self, irk: bytes) -> None: + """If no downstream caller is tracking this irk, lets forget it.""" + if irk in self._service_info_callbacks or irk in self._unavailable_callbacks: + return + + # Ignore availability events for this irk as no + # one is listening. + if cancel := self._unavailability_trackers.pop(irk, None): + cancel() + + del self._irks[irk] + + if mac := self._irk_to_mac.pop(irk, None): + self._mac_to_irk.pop(mac, None) + + if not self._mac_to_irk: + self._async_ensure_stopped() + + def async_track_service_info( + self, callback: bluetooth.BluetoothCallback, irk: bytes + ) -> Cancellable: + """Receive a callback when a new advertisement is received for an irk. + + Returns a callback that can be used to cancel the registration. + """ + self._async_ensure_started() + self._async_maybe_learn_irk(irk) + + callbacks = self._service_info_callbacks.setdefault(irk, []) + callbacks.append(callback) + + def _unsubscribe() -> None: + callbacks.remove(callback) + if not callbacks: + self._service_info_callbacks.pop(irk, None) + self._async_maybe_forget_irk(irk) + + return _unsubscribe + + def async_track_unavailable( + self, + callback: UnavailableCallback, + irk: bytes, + ) -> Cancellable: + """Register to receive a callback when an irk is unavailable. + + Returns a callback that can be used to cancel the registration. + """ + self._async_ensure_started() + self._async_maybe_learn_irk(irk) + + callbacks = self._unavailable_callbacks.setdefault(irk, []) + callbacks.append(callback) + + def _unsubscribe() -> None: + callbacks.remove(callback) + if not callbacks: + self._unavailable_callbacks.pop(irk, None) + + self._async_maybe_forget_irk(irk) + + return _unsubscribe + + +def async_get_coordinator(hass: HomeAssistant) -> PrivateDevicesCoordinator: + """Create or return an existing PrivateDeviceManager. + + There should only be one per HomeAssistant instance. Associating private + mac addresses with an IRK involves AES operations. We don't want to + duplicate that work. + """ + if existing := hass.data.get(DOMAIN): + return cast(PrivateDevicesCoordinator, existing) + + pdm = hass.data[DOMAIN] = PrivateDevicesCoordinator(hass) + + return pdm diff --git a/homeassistant/components/private_ble_device/device_tracker.py b/homeassistant/components/private_ble_device/device_tracker.py new file mode 100644 index 00000000000..64e23b25ebe --- /dev/null +++ b/homeassistant/components/private_ble_device/device_tracker.py @@ -0,0 +1,75 @@ +"""Tracking for bluetooth low energy devices.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging + +from homeassistant.components import bluetooth +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import BasePrivateDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Load Device Tracker entities for a config entry.""" + async_add_entities([BasePrivateDeviceTracker(config_entry)]) + + +class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseTrackerEntity): + """A trackable Private Bluetooth Device.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None + + @property + def extra_state_attributes(self) -> Mapping[str, str]: + """Return extra state attributes for this device.""" + if last_info := self._last_info: + return { + "current_address": last_info.address, + "source": last_info.source, + } + return {} + + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + self._last_info = None + self.async_write_ha_state() + + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + self._last_info = service_info + self.async_write_ha_state() + + @property + def state(self) -> str: + """Return the state of the device.""" + return STATE_HOME if self._last_info else STATE_NOT_HOME + + @property + def source_type(self) -> SourceType: + """Return the source type, eg gps or router, of the device.""" + return SourceType.BLUETOOTH_LE + + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:bluetooth-connect" if self._last_info else "mdi:bluetooth-off" diff --git a/homeassistant/components/private_ble_device/entity.py b/homeassistant/components/private_ble_device/entity.py new file mode 100644 index 00000000000..ae632213506 --- /dev/null +++ b/homeassistant/components/private_ble_device/entity.py @@ -0,0 +1,71 @@ +"""Tracking for bluetooth low energy devices.""" +from __future__ import annotations + +from abc import abstractmethod +import binascii + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .coordinator import async_get_coordinator, async_last_service_info + + +class BasePrivateDeviceEntity(Entity): + """Base Private Bluetooth Entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, config_entry: ConfigEntry) -> None: + """Set up a new BleScanner entity.""" + irk = config_entry.data["irk"] + + self._attr_unique_id = irk + + self._attr_device_info = DeviceInfo( + name=f"Private BLE Device {irk[:6]}", + identifiers={(DOMAIN, irk)}, + ) + + self._entry = config_entry + self._irk = binascii.unhexlify(irk) + self._last_info: bluetooth.BluetoothServiceInfoBleak | None = None + + async def async_added_to_hass(self) -> None: + """Configure entity when it is added to Home Assistant.""" + coordinator = async_get_coordinator(self.hass) + self.async_on_remove( + coordinator.async_track_service_info( + self._async_track_service_info, self._irk + ) + ) + self.async_on_remove( + coordinator.async_track_unavailable( + self._async_track_unavailable, self._irk + ) + ) + + if service_info := async_last_service_info(self.hass, self._irk): + self._async_track_service_info( + service_info, bluetooth.BluetoothChange.ADVERTISEMENT + ) + + @abstractmethod + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Respond when the bluetooth device being tracked is no longer visible.""" + + @abstractmethod + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Respond when the bluetooth device being tracked broadcasted updated information.""" diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json new file mode 100644 index 00000000000..3497138178c --- /dev/null +++ b/homeassistant/components/private_ble_device/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "private_ble_device", + "name": "Private BLE Device", + "codeowners": ["@Jc2k"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/private_ble_device", + "iot_class": "local_push", + "requirements": ["bluetooth-data-tools==1.11.0"] +} diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json new file mode 100644 index 00000000000..c62ea5c4d50 --- /dev/null +++ b/homeassistant/components/private_ble_device/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "What is the IRK (Identity Resolving Key) of the BLE device you want to track?", + "data": { + "irk": "IRK" + } + } + }, + "error": { + "irk_not_found": "The provided IRK does not match any BLE devices that Home Assistant can see.", + "irk_not_valid": "The key does not look like a valid IRK." + }, + "abort": { + "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7d84dc87cbe..6c992fd4b5e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -351,6 +351,7 @@ FLOWS = { "point", "poolsense", "powerwall", + "private_ble_device", "profiler", "progettihwsw", "prosegur", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c357b5aed4c..a9e19441693 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4320,6 +4320,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "private_ble_device": { + "name": "Private BLE Device", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "profiler": { "name": "Profiler", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 9802c26c3c6..178b82fd359 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2312,6 +2312,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.private_ble_device.*] +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.proximity.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index d72b41f442e..be7a06399d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -550,6 +550,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble +# homeassistant.components.private_ble_device bluetooth-data-tools==1.11.0 # homeassistant.components.bond diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3049cb33268..5362d5ac2b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -461,6 +461,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble +# homeassistant.components.private_ble_device bluetooth-data-tools==1.11.0 # homeassistant.components.bond diff --git a/tests/components/private_ble_device/__init__.py b/tests/components/private_ble_device/__init__.py new file mode 100644 index 00000000000..df9929293a1 --- /dev/null +++ b/tests/components/private_ble_device/__init__.py @@ -0,0 +1,78 @@ +"""Tests for private_ble_device.""" + +from datetime import timedelta +import time +from unittest.mock import patch + +from home_assistant_bluetooth import BluetoothServiceInfoBleak + +from homeassistant import config_entries +from homeassistant.components.private_ble_device.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + generate_advertisement_data, + generate_ble_device, + inject_bluetooth_service_info_bleak, +) + +MAC_RPA_VALID_1 = "40:01:02:0a:c4:a6" +MAC_RPA_VALID_2 = "40:02:03:d2:74:ce" +MAC_RPA_INVALID = "40:00:00:d2:74:ce" +MAC_STATIC = "00:01:ff:a0:3a:76" + +DUMMY_IRK = "00000000000000000000000000000000" + + +async def async_mock_config_entry(hass: HomeAssistant, irk: str = DUMMY_IRK) -> None: + """Create a test device for a dummy IRK.""" + entry = MockConfigEntry( + version=1, + domain=DOMAIN, + entry_id=irk, + data={"irk": irk}, + title="Private BLE Device 000000", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.LOADED + await hass.async_block_till_done() + + +async def async_inject_broadcast( + hass: HomeAssistant, + mac: str = MAC_RPA_VALID_1, + mfr_data: bytes = b"", + broadcast_time: float | None = None, +) -> None: + """Inject an advertisement.""" + inject_bluetooth_service_info_bleak( + hass, + BluetoothServiceInfoBleak( + name="Test Test Test", + address=mac, + rssi=-63, + service_data={}, + manufacturer_data={1: mfr_data}, + service_uuids=[], + source="local", + device=generate_ble_device(mac, "Test Test Test"), + advertisement=generate_advertisement_data(local_name="Not it"), + time=broadcast_time or time.monotonic(), + connectable=False, + ), + ) + await hass.async_block_till_done() + + +async def async_move_time_forwards(hass: HomeAssistant, offset: float): + """Mock time advancing from now to now+offset.""" + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=time.monotonic() + offset, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=offset)) + await hass.async_block_till_done() diff --git a/tests/components/private_ble_device/conftest.py b/tests/components/private_ble_device/conftest.py new file mode 100644 index 00000000000..b33dc1d4ea2 --- /dev/null +++ b/tests/components/private_ble_device/conftest.py @@ -0,0 +1 @@ +"""private_ble_device fixtures.""" diff --git a/tests/components/private_ble_device/test_config_flow.py b/tests/components/private_ble_device/test_config_flow.py new file mode 100644 index 00000000000..aa8ea0d905c --- /dev/null +++ b/tests/components/private_ble_device/test_config_flow.py @@ -0,0 +1,132 @@ +"""Tests for private bluetooth device config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.private_ble_device import const +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.components.bluetooth import inject_bluetooth_service_info + + +def assert_form_error(result: FlowResult, key: str, value: str) -> None: + """Assert that a flow returned a form error.""" + assert result["type"] == "form" + assert result["errors"] + assert result["errors"][key] == value + + +async def test_setup_user_no_bluetooth( + hass: HomeAssistant, mock_bluetooth_adapters: None +) -> None: + """Test setting up via user interaction when bluetooth is not enabled.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "bluetooth_not_available" + + +async def test_invalid_irk(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test invalid irk.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"irk": "irk:000000"} + ) + assert_form_error(result, "irk", "irk_not_valid") + + +async def test_irk_not_found(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test irk not found.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"irk": "irk:00000000000000000000000000000000"}, + ) + assert_form_error(result, "irk", "irk_not_found") + + +async def test_flow_works(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test config flow works.""" + + inject_bluetooth_service_info( + hass, + BluetoothServiceInfo( + name="Test Test Test", + address="40:01:02:0a:c4:a6", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", + ), + ) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + # Check you can finish the flow + with patch( + "homeassistant.components.private_ble_device.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"irk": "irk:00000000000000000000000000000000"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Test Test" + assert result["data"] == {"irk": "00000000000000000000000000000000"} + assert result["result"].unique_id == "00000000000000000000000000000000" + + +async def test_flow_works_by_base64( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test config flow works.""" + + inject_bluetooth_service_info( + hass, + BluetoothServiceInfo( + name="Test Test Test", + address="40:01:02:0a:c4:a6", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", + ), + ) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + # Check you can finish the flow + with patch( + "homeassistant.components.private_ble_device.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"irk": "AAAAAAAAAAAAAAAAAAAAAA=="}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Test Test" + assert result["data"] == {"irk": "00000000000000000000000000000000"} + assert result["result"].unique_id == "00000000000000000000000000000000" diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py new file mode 100644 index 00000000000..776ba503983 --- /dev/null +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -0,0 +1,183 @@ +"""Tests for polling measures.""" + + +import time + +from homeassistant.components.bluetooth.advertisement_tracker import ( + ADVERTISING_TIMES_NEEDED, +) +from homeassistant.core import HomeAssistant + +from . import ( + MAC_RPA_VALID_1, + MAC_RPA_VALID_2, + MAC_STATIC, + async_inject_broadcast, + async_mock_config_entry, + async_move_time_forwards, +) + +from tests.components.bluetooth.test_advertisement_tracker import ONE_HOUR_SECONDS + + +async def test_tracker_created(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test creating a tracker entity when no devices have been seen.""" + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_ignore_other_rpa( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test that tracker ignores RPA's that don't match us.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_STATIC) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_already_home( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test creating a tracker and the device was already discovered by HA.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + +async def test_tracker_arrive_home(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test transition from not_home to home.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"1") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == "40:01:02:0a:c4:a6" + assert state.attributes["source"] == "local" + + await async_inject_broadcast(hass, MAC_STATIC, b"1") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # Test same wrong mac address again to exercise some caching + await async_inject_broadcast(hass, MAC_STATIC, b"2") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # And test original mac address again. + # Use different mfr data so that event bubbles up + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"2") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == "40:01:02:0a:c4:a6" + + +async def test_tracker_isolation(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test creating 2 tracker entities doesn't confuse anything.""" + await async_mock_config_entry(hass) + await async_mock_config_entry(hass, irk="1" * 32) + + # This broadcast should only impact the first entity + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"1") + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + state = hass.states.get("device_tracker.private_ble_device_111111") + assert state + assert state.state == "not_home" + + +async def test_tracker_mac_rotate(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test MAC address rotation.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == MAC_RPA_VALID_1 + + await async_inject_broadcast(hass, MAC_RPA_VALID_2) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == MAC_RPA_VALID_2 + + +async def test_tracker_start_stale(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test edge case where we find an existing stale record, and it expires before we see any more.""" + time.monotonic() + + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + await async_move_time_forwards( + hass, ((ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS) + ) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_leave_home(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test tracker notices we have left.""" + time.monotonic() + + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + await async_move_time_forwards( + hass, ((ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS) + ) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_old_tracker_leave_home( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test tracker ignores an old stale mac address timing out.""" + start_time = time.monotonic() + + await async_mock_config_entry(hass) + + await async_inject_broadcast(hass, MAC_RPA_VALID_2, broadcast_time=start_time) + await async_inject_broadcast(hass, MAC_RPA_VALID_2, broadcast_time=start_time + 15) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # First address has timed out - still home + await async_move_time_forwards(hass, 910) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # Second address has time out - now away + await async_move_time_forwards(hass, 920) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" From 9e9aa163f70ada036c59f5a1dbd9df269ff704d2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 2 Sep 2023 16:42:32 +0200 Subject: [PATCH 057/984] Use shorthand attributes in hlk_sw16 (#99383) --- homeassistant/components/hlk_sw16/__init__.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index f80972da613..9be0b5203fd 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -147,12 +147,8 @@ class SW16Device(Entity): self._device_port = device_port self._is_on = None self._client = client - self._name = device_port - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._entry_id}_{self._device_port}" + self._attr_name = device_port + self._attr_unique_id = f"{self._entry_id}_{self._device_port}" @callback def handle_event_callback(self, event): @@ -161,11 +157,6 @@ class SW16Device(Entity): self._is_on = event self.async_write_ha_state() - @property - def name(self): - """Return a name for the device.""" - return self._name - @property def available(self): """Return True if entity is available.""" From 7168e71860ff5c04aafbf5de240026b2963043ff Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 2 Sep 2023 16:51:06 +0200 Subject: [PATCH 058/984] Log unexpected exceptions causing recorder shutdown (#99445) --- homeassistant/components/recorder/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index ffdc3807039..bbaff24ff77 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -692,6 +692,10 @@ class Recorder(threading.Thread): """Run the recorder thread.""" try: self._run() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception( + "Recorder._run threw unexpected exception, recorder shutting down" + ) finally: # Ensure shutdown happens cleanly if # anything goes wrong in the run loop From defd9e400179f568a053cf4c86471830e03206fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 2 Sep 2023 17:09:46 +0200 Subject: [PATCH 059/984] Don't compile missing statistics when running tests (#99446) --- tests/conftest.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index f90984e1c7b..739dfa5d292 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1276,6 +1276,11 @@ def hass_recorder( hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None + compile_missing = ( + recorder.Recorder._schedule_compile_missing_statistics + if enable_statistics + else None + ) schema_validate = ( migration._find_schema_errors if enable_schema_validation @@ -1327,6 +1332,10 @@ def hass_recorder( "homeassistant.components.recorder.Recorder._migrate_entity_ids", side_effect=migrate_entity_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", + side_effect=compile_missing, + autospec=True, ): def setup_recorder(config: dict[str, Any] | None = None) -> HomeAssistant: @@ -1399,6 +1408,11 @@ async def async_setup_recorder_instance( if enable_schema_validation else itertools.repeat(set()) ) + compile_missing = ( + recorder.Recorder._schedule_compile_missing_statistics + if enable_statistics + else None + ) migrate_states_context_ids = ( recorder.Recorder._migrate_states_context_ids if enable_migrate_context_ids @@ -1445,6 +1459,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.Recorder._migrate_entity_ids", side_effect=migrate_entity_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", + side_effect=compile_missing, + autospec=True, ): async def async_setup_recorder( From 6e743a5bb2b7a56cd68d0663943ea6e220e141d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 11:55:11 -0500 Subject: [PATCH 060/984] Switch mqtt to use async_call_later where possible (#99486) --- homeassistant/components/mqtt/binary_sensor.py | 18 +++++++++--------- homeassistant/components/mqtt/sensor.py | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 0d4b2c4a7b4..b5c7bc98789 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -29,7 +29,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -128,15 +128,17 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): expiration_at: datetime = last_state.last_changed + timedelta( seconds=self._expire_after ) - if expiration_at < (time_now := dt_util.utcnow()): + remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds() + + if remain_seconds <= 0: # Skip reactivating the binary_sensor _LOGGER.debug("Skip state recovery after reload for %s", self.entity_id) return self._expired = False self._attr_is_on = last_state.state == STATE_ON - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, remain_seconds, self._value_is_expired ) _LOGGER.debug( ( @@ -144,7 +146,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): " expiring %s" ), self.entity_id, - expiration_at - time_now, + remain_seconds, ) async def async_will_remove_from_hass(self) -> None: @@ -202,10 +204,8 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): self._expiration_trigger() # Set new trigger - expiration_at = dt_util.utcnow() + timedelta(seconds=self._expire_after) - - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, self._expire_after, self._value_is_expired ) payload = self._value_template(msg.payload) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index ae94b0df0ce..70c8d505b4f 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -32,7 +32,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -162,15 +162,17 @@ class MqttSensor(MqttEntity, RestoreSensor): and not self._expiration_trigger ): expiration_at = last_state.last_changed + timedelta(seconds=_expire_after) - if expiration_at < (time_now := dt_util.utcnow()): + remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds() + + if remain_seconds <= 0: # Skip reactivating the sensor _LOGGER.debug("Skip state recovery after reload for %s", self.entity_id) return self._expired = False self._attr_native_value = last_sensor_data.native_value - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, remain_seconds, self._value_is_expired ) _LOGGER.debug( ( @@ -178,7 +180,7 @@ class MqttSensor(MqttEntity, RestoreSensor): " expiring %s" ), self.entity_id, - expiration_at - time_now, + remain_seconds, ) async def async_will_remove_from_hass(self) -> None: @@ -235,10 +237,8 @@ class MqttSensor(MqttEntity, RestoreSensor): self._expiration_trigger() # Set new trigger - expiration_at = dt_util.utcnow() + timedelta(seconds=self._expire_after) - - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, self._expire_after, self._value_is_expired ) payload = self._template(msg.payload, PayloadSentinel.DEFAULT) From acd9cfa929646a6a1fcad807e3e4b5c6c02565d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 12:08:07 -0500 Subject: [PATCH 061/984] Speed up calls to the all states api (#99462) --- homeassistant/components/api/__init__.py | 21 +++++++++----- tests/components/api/test_init.py | 37 ++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 7b13833ccab..b427341546e 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -9,6 +9,7 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import HomeAssistantView, require_admin @@ -189,16 +190,20 @@ class APIStatesView(HomeAssistantView): name = "api:states" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get current states.""" - user = request["hass_user"] + user: User = request["hass_user"] + hass: HomeAssistant = request.app["hass"] + if user.is_admin: + return self.json([state.as_dict() for state in hass.states.async_all()]) entity_perm = user.permissions.check_entity - states = [ - state - for state in request.app["hass"].states.async_all() - if entity_perm(state.entity_id, "read") - ] - return self.json(states) + return self.json( + [ + state.as_dict() + for state in hass.states.async_all() + if entity_perm(state.entity_id, "read") + ] + ) class APIEntityStateView(HomeAssistantView): diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 116529b02a4..f61988eff5a 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -9,6 +9,7 @@ import pytest import voluptuous as vol from homeassistant import const +from homeassistant.auth.models import Credentials from homeassistant.auth.providers.legacy_api_password import ( LegacyApiPasswordAuthProvider, ) @@ -17,7 +18,7 @@ import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockUser, async_mock_service +from tests.common import CLIENT_ID, MockUser, async_mock_service from tests.typing import ClientSessionGenerator @@ -569,11 +570,41 @@ async def test_event_stream_requires_admin( assert resp.status == HTTPStatus.UNAUTHORIZED -async def test_states_view_filters( +async def test_states( hass: HomeAssistant, mock_api_client: TestClient, hass_admin_user: MockUser +) -> None: + """Test fetching all states as admin.""" + hass.states.async_set("test.entity", "hello") + resp = await mock_api_client.get(const.URL_API_STATES) + assert resp.status == HTTPStatus.OK + json = await resp.json() + assert len(json) == 1 + assert json[0]["entity_id"] == "test.entity" + + +async def test_states_view_filters( + hass: HomeAssistant, + hass_read_only_user: MockUser, + hass_client: ClientSessionGenerator, ) -> None: """Test filtering only visible states.""" - hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) + assert not hass_read_only_user.is_admin + hass_read_only_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) + await async_setup_component(hass, "api", {}) + read_only_user_credential = Credentials( + id="mock-read-only-credential-id", + auth_provider_type="homeassistant", + auth_provider_id=None, + data={"username": "readonly"}, + is_new=False, + ) + await hass.auth.async_link_user(hass_read_only_user, read_only_user_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_read_only_user, CLIENT_ID, credential=read_only_user_credential + ) + token = hass.auth.async_create_access_token(refresh_token) + mock_api_client = await hass_client(token) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") resp = await mock_api_client.get(const.URL_API_STATES) From 6974d211e55cbcd042c3216c9c0c38801e41f01e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 12:14:33 -0500 Subject: [PATCH 062/984] Switch isy994 to use async_call_later (#99487) async_track_point_in_utc_time is not needed here since we only need to call at timedelta(hours=25) --- homeassistant/components/isy994/binary_sensor.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index aa7c3d55147..27f1887bd92 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -23,9 +23,8 @@ from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt as dt_util from .const import ( _LOGGER, @@ -496,15 +495,8 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity) self._heartbeat_timer = None self.async_write_ha_state() - point_in_time = dt_util.utcnow() + timedelta(hours=25) - _LOGGER.debug( - "Heartbeat timer starting. Now: %s Then: %s", - dt_util.utcnow(), - point_in_time, - ) - - self._heartbeat_timer = async_track_point_in_utc_time( - self.hass, timer_elapsed, point_in_time + self._heartbeat_timer = async_call_later( + self.hass, timedelta(hours=25), timer_elapsed ) @callback From c3841f8734b24aa68045d4172b04149c34ddb98e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Sep 2023 19:26:11 +0200 Subject: [PATCH 063/984] Rework on mqtt certificate tests (#99503) * Shared fixture on TEMP_DIR_NAME mock in MQTT tests * Improve mqtt certificate file tests * Update tests/components/mqtt/test_util.py Co-authored-by: J. Nick Koston * Update tests/components/mqtt/conftest.py Co-authored-by: J. Nick Koston * Avoid blocking code * typo in sub function --------- Co-authored-by: J. Nick Koston --- tests/components/mqtt/conftest.py | 21 +++++++ tests/components/mqtt/test_config_flow.py | 11 ++-- tests/components/mqtt/test_util.py | 74 ++++++++++++++++------- 3 files changed, 77 insertions(+), 29 deletions(-) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index ebe86c1f1df..91ece381f6d 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -1,5 +1,9 @@ """Test fixtures for mqtt component.""" +from collections.abc import Generator +from random import getrandbits +from unittest.mock import patch + import pytest from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -8,3 +12,20 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture(autouse=True) def patch_hass_config(mock_hass_config: None) -> None: """Patch configuration.yaml.""" + + +@pytest.fixture +def temp_dir_prefix() -> str: + """Set an alternate temp dir prefix.""" + return "test" + + +@pytest.fixture +def mock_temp_dir(temp_dir_prefix: str) -> Generator[None, None, str]: + """Mock the certificate temp directory.""" + with patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + f"home-assistant-mqtt-{temp_dir_prefix}-{getrandbits(10):03x}", + ) as mocked_temp_dir: + yield mocked_temp_dir diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index f0681a537da..c2a7e0065ce 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2,7 +2,6 @@ from collections.abc import Generator, Iterator from contextlib import contextmanager from pathlib import Path -from random import getrandbits from ssl import SSLError from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -131,7 +130,9 @@ def mock_try_connection_time_out() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_process_uploaded_file(tmp_path: Path) -> Generator[MagicMock, None, None]: +def mock_process_uploaded_file( + tmp_path: Path, mock_temp_dir: str +) -> Generator[MagicMock, None, None]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) file_id_cert = str(uuid4()) @@ -159,11 +160,7 @@ def mock_process_uploaded_file(tmp_path: Path) -> Generator[MagicMock, None, Non with patch( "homeassistant.components.mqtt.config_flow.process_uploaded_file", side_effect=_mock_process_uploaded_file, - ) as mock_upload, patch( - # Patch temp dir name to avoid tests fail running in parallel - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - "home-assistant-mqtt" + f"-{getrandbits(10):03x}", - ): + ) as mock_upload: mock_upload.file_id = { mqtt.CONF_CERTIFICATE: file_id_ca, mqtt.CONF_CLIENT_CERT: file_id_cert, diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index e93a5e376bb..941072bc224 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -1,7 +1,9 @@ """Test MQTT utils.""" from collections.abc import Callable +from pathlib import Path from random import getrandbits +import tempfile from unittest.mock import patch import pytest @@ -14,17 +16,6 @@ from tests.common import MockConfigEntry from tests.typing import MqttMockHAClient, MqttMockPahoClient -@pytest.fixture(autouse=True) -def mock_temp_dir(): - """Mock the certificate temp directory.""" - with patch( - # Patch temp dir name to avoid tests fail running in parallel - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - "home-assistant-mqtt" + f"-{getrandbits(10):03x}", - ) as mocked_temp_dir: - yield mocked_temp_dir - - @pytest.mark.parametrize( ("option", "content", "file_created"), [ @@ -34,31 +25,50 @@ def mock_temp_dir(): (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###", True), ], ) +@pytest.mark.parametrize("temp_dir_prefix", ["create-test"]) async def test_async_create_certificate_temp_files( - hass: HomeAssistant, mock_temp_dir, option, content, file_created + hass: HomeAssistant, + mock_temp_dir: str, + option: str, + content: str, + file_created: bool, ) -> None: """Test creating and reading and recovery certificate files.""" config = {option: content} - await mqtt.util.async_create_certificate_temp_files(hass, config) - file_path = mqtt.util.get_file_path(option) + temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir + + # Create old file to be able to assert it is removed with auto option + def _ensure_old_file_exists() -> None: + if not temp_dir.exists(): + temp_dir.mkdir(0o700) + temp_file = temp_dir / option + with open(temp_file, "wb") as old_file: + old_file.write(b"old content") + old_file.close() + + await hass.async_add_executor_job(_ensure_old_file_exists) + await mqtt.util.async_create_certificate_temp_files(hass, config) + file_path = await hass.async_add_executor_job(mqtt.util.get_file_path, option) assert bool(file_path) is file_created assert ( - mqtt.util.migrate_certificate_file_to_content(file_path or content) == content + await hass.async_add_executor_job( + mqtt.util.migrate_certificate_file_to_content, file_path or content + ) + == content ) # Make sure certificate temp files are recovered - if file_path: - # Overwrite content of file (except for auto option) - file = open(file_path, "wb") - file.write(b"invalid") - file.close() + await hass.async_add_executor_job(_ensure_old_file_exists) await mqtt.util.async_create_certificate_temp_files(hass, config) - file_path2 = mqtt.util.get_file_path(option) + file_path2 = await hass.async_add_executor_job(mqtt.util.get_file_path, option) assert bool(file_path2) is file_created assert ( - mqtt.util.migrate_certificate_file_to_content(file_path2 or content) == content + await hass.async_add_executor_job( + mqtt.util.migrate_certificate_file_to_content, file_path2 or content + ) + == content ) assert file_path == file_path2 @@ -71,6 +81,26 @@ async def test_reading_non_exitisting_certificate_file() -> None: ) +@pytest.mark.parametrize("temp_dir_prefix", "unknown") +async def test_return_default_get_file_path( + hass: HomeAssistant, mock_temp_dir: str +) -> None: + """Test get_file_path returns default.""" + + def _get_file_path(file_path: Path) -> bool: + return ( + not file_path.exists() + and mqtt.util.get_file_path("some_option", "mydefault") == "mydefault" + ) + + with patch( + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + f"home-assistant-mqtt-other-{getrandbits(10):03x}", + ) as mock_temp_dir: + tempdir = Path(tempfile.gettempdir()) / mock_temp_dir + assert await hass.async_add_executor_job(_get_file_path, tempdir) + + @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_waiting_for_client_not_loaded( hass: HomeAssistant, From 1048f47a915c3b89bdcac5dca54320b7535e1431 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Sep 2023 10:38:41 -0700 Subject: [PATCH 064/984] Code cleanup for nest device info (#99511) --- homeassistant/components/nest/device_info.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 35e32ccf1bc..f269e3e89d6 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -66,10 +66,7 @@ class NestDeviceInfo: @property def device_model(self) -> str | None: """Return device model information.""" - # The API intentionally returns minimal information about specific - # devices, instead relying on traits, but we can infer a generic model - # name based on the type - return DEVICE_TYPE_MAP.get(self._device.type or "", None) + return DEVICE_TYPE_MAP.get(self._device.type) if self._device.type else None @property def suggested_area(self) -> str | None: From 1ab2e900f9a61f35be4dcaa2551140d7a5cd79b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 12:43:27 -0500 Subject: [PATCH 065/984] Improve lingering timer checks (#99472) --- homeassistant/core.py | 4 ++++ tests/conftest.py | 35 +++++++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 18c5c355ae9..89269ae9158 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -294,6 +294,10 @@ class HomeAssistant: _hass.hass = hass return hass + def __repr__(self) -> str: + """Return the representation.""" + return f"" + def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" self.loop = asyncio.get_running_loop() diff --git a/tests/conftest.py b/tests/conftest.py index 739dfa5d292..99db0884496 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Callable, Coroutine, Generator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager import functools import gc import itertools import logging import os +import reprlib import sqlite3 import ssl import threading @@ -302,6 +303,21 @@ def skip_stop_scripts( yield +@contextmanager +def long_repr_strings() -> Generator[None, None, None]: + """Increase reprlib maxstring and maxother to 300.""" + arepr = reprlib.aRepr + original_maxstring = arepr.maxstring + original_maxother = arepr.maxother + arepr.maxstring = 300 + arepr.maxother = 300 + try: + yield + finally: + arepr.maxstring = original_maxstring + arepr.maxother = original_maxother + + @pytest.fixture(autouse=True) def verify_cleanup( event_loop: asyncio.AbstractEventLoop, @@ -335,13 +351,16 @@ def verify_cleanup( for handle in event_loop._scheduled: # type: ignore[attr-defined] if not handle.cancelled(): - if expected_lingering_timers: - _LOGGER.warning("Lingering timer after test %r", handle) - elif handle._args and isinstance(job := handle._args[0], HassJob): - pytest.fail(f"Lingering timer after job {repr(job)}") - else: - pytest.fail(f"Lingering timer after test {repr(handle)}") - handle.cancel() + with long_repr_strings(): + if expected_lingering_timers: + _LOGGER.warning("Lingering timer after test %r", handle) + elif handle._args and isinstance(job := handle._args[-1], HassJob): + if job.cancel_on_shutdown: + continue + pytest.fail(f"Lingering timer after job {repr(job)}") + else: + pytest.fail(f"Lingering timer after test {repr(handle)}") + handle.cancel() # Verify no threads where left behind. threads = frozenset(threading.enumerate()) - threads_before From 834f3810d325fbd2f585e62fb4042968156e0dd6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 2 Sep 2023 21:00:33 +0200 Subject: [PATCH 066/984] Check new IP of Reolink camera from DHCP (#99381) Co-authored-by: J. Nick Koston --- .../components/reolink/config_flow.py | 44 +++++++++- homeassistant/components/reolink/util.py | 23 +++++ tests/components/reolink/test_config_flow.py | 85 ++++++++++++++++--- 3 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/reolink/util.py diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index d24fd8d1f14..d924f395c50 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -12,13 +12,14 @@ from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost +from .util import has_connection_problem _LOGGER = logging.getLogger(__name__) @@ -96,7 +97,46 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) - await self.async_set_unique_id(mac_address) + existing_entry = await self.async_set_unique_id(mac_address) + if ( + existing_entry + and CONF_PASSWORD in existing_entry.data + and existing_entry.data[CONF_HOST] != discovery_info.ip + ): + if has_connection_problem(self.hass, existing_entry): + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', " + "but connection to camera seems to be okay, so sticking to IP '%s'", + discovery_info.ip, + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") + + # check if the camera is reachable at the new IP + host = ReolinkHost(self.hass, existing_entry.data, existing_entry.options) + try: + await host.api.get_state("GetLocalLink") + await host.api.logout() + except ReolinkError as err: + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', " + "but got error '%s' trying to connect, so sticking to IP '%s'", + discovery_info.ip, + str(err), + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") from err + if format_mac(host.api.mac_address) != mac_address: + _LOGGER.debug( + "Reolink mac address '%s' at new IP '%s' from DHCP, " + "does not match mac '%s' of config entry, so sticking to IP '%s'", + format_mac(host.api.mac_address), + discovery_info.ip, + mac_address, + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self.context["title_placeholders"] = { diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py new file mode 100644 index 00000000000..2ab625647a7 --- /dev/null +++ b/homeassistant/components/reolink/util.py @@ -0,0 +1,23 @@ +"""Utility functions for the Reolink component.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant + +from . import ReolinkData +from .const import DOMAIN + + +def has_connection_problem( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Check if a existing entry has a connection problem.""" + reolink_data: ReolinkData | None = hass.data.get(DOMAIN, {}).get( + config_entry.entry_id + ) + connection_problem = ( + reolink_data is not None + and config_entry.state == config_entries.ConfigEntryState.LOADED + and reolink_data.device_coordinator.last_update_success + ) + return connection_problem diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 048b48d9576..1a4bf999cce 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -1,18 +1,22 @@ """Test the Reolink config flow.""" +from datetime import timedelta import json -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.components.reolink import const +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.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.util.dt import utcnow from .conftest import ( TEST_HOST, @@ -27,12 +31,14 @@ from .conftest import ( TEST_USERNAME2, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") +pytestmark = pytest.mark.usefixtures("reolink_connect") -async def test_config_flow_manual_success(hass: HomeAssistant) -> None: +async def test_config_flow_manual_success( + hass: HomeAssistant, mock_setup_entry: MagicMock +) -> None: """Successful flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -66,7 +72,7 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None: async def test_config_flow_errors( - hass: HomeAssistant, reolink_connect: MagicMock + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( @@ -192,7 +198,7 @@ async def test_config_flow_errors( } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -230,7 +236,9 @@ async def test_options_flow(hass: HomeAssistant) -> None: } -async def test_change_connection_settings(hass: HomeAssistant) -> None: +async def test_change_connection_settings( + hass: HomeAssistant, mock_setup_entry: MagicMock +) -> None: """Test changing connection settings by issuing a second user config flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -273,7 +281,7 @@ async def test_change_connection_settings(hass: HomeAssistant) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -333,7 +341,7 @@ async def test_reauth(hass: HomeAssistant) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 -async def test_dhcp_flow(hass: HomeAssistant) -> None: +async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" dhcp_data = dhcp.DhcpServiceInfo( ip=TEST_HOST, @@ -371,8 +379,44 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: } -async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: - """Test dhcp discovery aborts if already configured.""" +@pytest.mark.parametrize( + ("last_update_success", "attr", "value", "expected"), + [ + ( + False, + None, + None, + TEST_HOST2, + ), + ( + True, + None, + None, + TEST_HOST, + ), + ( + False, + "get_state", + AsyncMock(side_effect=ReolinkError("Test error")), + TEST_HOST, + ), + ( + False, + "mac_address", + "aa:aa:aa:aa:aa:aa", + TEST_HOST, + ), + ], +) +async def test_dhcp_ip_update( + hass: HomeAssistant, + reolink_connect: MagicMock, + last_update_success: bool, + attr: str, + value: Any, + expected: str, +) -> None: + """Test dhcp discovery aborts if already configured where the IP is updated if appropriate.""" config_entry = MockConfigEntry( domain=const.DOMAIN, unique_id=format_mac(TEST_MAC), @@ -392,16 +436,31 @@ async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + if not last_update_success: + # ensure the last_update_succes is False for the device_coordinator. + reolink_connect.get_states = AsyncMock(side_effect=ReolinkError("Test error")) + async_fire_time_changed( + hass, utcnow() + DEVICE_UPDATE_INTERVAL + timedelta(minutes=1) + ) + await hass.async_block_till_done() dhcp_data = dhcp.DhcpServiceInfo( - ip=TEST_HOST, + ip=TEST_HOST2, hostname="Reolink", macaddress=TEST_MAC, ) + if attr is not None: + setattr(reolink_connect, attr, value) + result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data ) assert result["type"] is data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert config_entry.data[CONF_HOST] == expected From 0b065b118b5481502e50b37f2a748960fc925698 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 14:04:13 -0500 Subject: [PATCH 067/984] Bump zeroconf to 0.91.1 (#99490) --- 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 79b7e514f51..26577bd0bbe 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.88.0"] + "requirements": ["zeroconf==0.91.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 286bc927d45..8069d5c0e70 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.88.0 +zeroconf==0.91.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index be7a06399d2..9898a0b8f46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.88.0 +zeroconf==0.91.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5362d5ac2b5..d2a3bb2e718 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.88.0 +zeroconf==0.91.1 # homeassistant.components.zeversolar zeversolar==0.3.1 From 3c30ad1850bc52761c74fa2f0094845c30b49418 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 2 Sep 2023 21:51:58 +0200 Subject: [PATCH 068/984] Motion blinds duplication reduction using entity baseclass (#99444) --- .coveragerc | 1 + .../components/motion_blinds/__init__.py | 23 +---- .../components/motion_blinds/const.py | 1 - .../components/motion_blinds/cover.py | 56 ++--------- .../components/motion_blinds/entity.py | 94 +++++++++++++++++++ .../components/motion_blinds/sensor.py | 84 +++-------------- 6 files changed, 117 insertions(+), 142 deletions(-) create mode 100644 homeassistant/components/motion_blinds/entity.py diff --git a/.coveragerc b/.coveragerc index d5a491a330f..d28878d8861 100644 --- a/.coveragerc +++ b/.coveragerc @@ -750,6 +750,7 @@ omit = homeassistant/components/moehlenhoff_alpha2/sensor.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/cover.py + homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 9ea0f6ddbc9..188f3a784ac 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -5,13 +5,12 @@ import logging from socket import timeout from typing import TYPE_CHECKING, Any -from motionblinds import DEVICE_TYPES_WIFI, AsyncMotionMulticast, ParseException +from motionblinds import AsyncMotionMulticast, ParseException from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -27,8 +26,6 @@ from .const import ( KEY_MULTICAST_LISTENER, KEY_SETUP_LOCK, KEY_UNSUB_STOP, - KEY_VERSION, - MANUFACTURER, PLATFORMS, UPDATE_INTERVAL, UPDATE_INTERVAL_FAST, @@ -183,32 +180,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - if motion_gateway.firmware is not None: - version = f"{motion_gateway.firmware}, protocol: {motion_gateway.protocol}" - else: - version = f"Protocol: {motion_gateway.protocol}" - hass.data[DOMAIN][entry.entry_id] = { KEY_GATEWAY: motion_gateway, KEY_COORDINATOR: coordinator, - KEY_VERSION: version, } if TYPE_CHECKING: assert entry.unique_id is not None - if motion_gateway.device_type not in DEVICE_TYPES_WIFI: - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, motion_gateway.mac)}, - identifiers={(DOMAIN, motion_gateway.mac)}, - manufacturer=MANUFACTURER, - name=entry.title, - model="Wi-Fi bridge", - sw_version=version, - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index d241f03a02e..429259a91c1 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -18,7 +18,6 @@ KEY_COORDINATOR = "coordinator" KEY_MULTICAST_LISTENER = "multicast_listener" KEY_SETUP_LOCK = "setup_lock" KEY_UNSUB_STOP = "unsub_stop" -KEY_VERSION = "version" ATTR_WIDTH = "width" ATTR_ABSOLUTE_POSITION = "absolute_position" diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index c9578380048..1a4507f1066 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -16,15 +16,9 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_platform, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_ABSOLUTE_POSITION, @@ -33,13 +27,12 @@ from .const import ( DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, - KEY_VERSION, - MANUFACTURER, SERVICE_SET_ABSOLUTE_POSITION, UPDATE_DELAY_STOP, UPDATE_INTERVAL_MOVING, UPDATE_INTERVAL_MOVING_WIFI, ) +from .entity import MotionCoordinatorEntity from .gateway import device_name _LOGGER = logging.getLogger(__name__) @@ -96,7 +89,6 @@ async def async_setup_entry( entities = [] motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - sw_version = hass.data[DOMAIN][config_entry.entry_id][KEY_VERSION] for blind in motion_gateway.device_list.values(): if blind.type in POSITION_DEVICE_MAP: @@ -105,7 +97,6 @@ async def async_setup_entry( coordinator, blind, POSITION_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -115,7 +106,6 @@ async def async_setup_entry( coordinator, blind, TILT_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -125,7 +115,6 @@ async def async_setup_entry( coordinator, blind, TILT_ONLY_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -135,7 +124,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Top", ) ) @@ -144,7 +132,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Bottom", ) ) @@ -153,7 +140,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Combined", ) ) @@ -168,7 +154,6 @@ async def async_setup_entry( coordinator, blind, POSITION_DEVICE_MAP[BlindType.RollerBlind], - sw_version, ) ) @@ -182,44 +167,27 @@ async def async_setup_entry( ) -class MotionPositionDevice(CoordinatorEntity, CoverEntity): +class MotionPositionDevice(MotionCoordinatorEntity, CoverEntity): """Representation of a Motion Blind Device.""" _restore_tilt = False - def __init__(self, coordinator, blind, device_class, sw_version): + def __init__(self, coordinator, blind, device_class): """Initialize the blind.""" - super().__init__(coordinator) + super().__init__(coordinator, blind) - self._blind = blind - self._api_lock = coordinator.api_lock self._requesting_position: CALLBACK_TYPE | None = None self._previous_positions = [] if blind.device_type in DEVICE_TYPES_WIFI: self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI - via_device = () - connections = {(dr.CONNECTION_NETWORK_MAC, blind.mac)} else: self._update_interval_moving = UPDATE_INTERVAL_MOVING - via_device = (DOMAIN, blind._gateway.mac) - connections = {} - sw_version = None name = device_name(blind) self._attr_device_class = device_class self._attr_name = name self._attr_unique_id = blind.mac - self._attr_device_info = DeviceInfo( - connections=connections, - identifiers={(DOMAIN, blind.mac)}, - manufacturer=MANUFACTURER, - model=blind.blind_type, - name=name, - via_device=via_device, - sw_version=sw_version, - hw_version=blind.wireless_name, - ) @property def available(self) -> bool: @@ -249,16 +217,6 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): return None return self._blind.position == 100 - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes and register signal handler.""" - self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._blind.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() - async def async_scheduled_update_request(self, *_): """Request a state update from the blind at a scheduled point in time.""" # add the last position to the list and keep the list at max 2 items @@ -439,9 +397,9 @@ class MotionTiltOnlyDevice(MotionTiltDevice): class MotionTDBUDevice(MotionPositionDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" - def __init__(self, coordinator, blind, device_class, sw_version, motor): + def __init__(self, coordinator, blind, device_class, motor): """Initialize the blind.""" - super().__init__(coordinator, blind, device_class, sw_version) + super().__init__(coordinator, blind, device_class) self._motor = motor self._motor_key = motor[0] self._attr_name = f"{device_name(blind)} {motor}" diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py new file mode 100644 index 00000000000..d57d7401b47 --- /dev/null +++ b/homeassistant/components/motion_blinds/entity.py @@ -0,0 +1,94 @@ +"""Support for Motion Blinds using their WLAN API.""" +from __future__ import annotations + +from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, MotionGateway +from motionblinds.motion_blinds import MotionBlind + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DataUpdateCoordinatorMotionBlinds +from .const import ( + ATTR_AVAILABLE, + DEFAULT_GATEWAY_NAME, + DOMAIN, + KEY_GATEWAY, + MANUFACTURER, +) +from .gateway import device_name + + +class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]): + """Representation of a Motion Blind entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinatorMotionBlinds, + blind: MotionGateway | MotionBlind, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._blind = blind + self._api_lock = coordinator.api_lock + + if blind.device_type in DEVICE_TYPES_GATEWAY: + gateway = blind + else: + gateway = blind._gateway + if gateway.firmware is not None: + sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}" + else: + sw_version = f"Protocol: {gateway.protocol}" + + if blind.device_type in DEVICE_TYPES_GATEWAY: + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + name=DEFAULT_GATEWAY_NAME, + model="Wi-Fi bridge", + sw_version=sw_version, + ) + elif blind.device_type in DEVICE_TYPES_WIFI: + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + model=blind.blind_type, + name=device_name(blind), + sw_version=sw_version, + hw_version=blind.wireless_name, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + model=blind.blind_type, + name=device_name(blind), + via_device=(DOMAIN, blind._gateway.mac), + hw_version=blind.wireless_name, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.coordinator.data is None: + return False + + gateway_available = self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE] + if not gateway_available or self._blind.device_type in DEVICE_TYPES_GATEWAY: + return gateway_available + + return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] + + async def async_added_to_hass(self) -> None: + """Subscribe to multicast pushes and register signal handler.""" + self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe when removed.""" + self._blind.Remove_callback(self.unique_id) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index bca1c1ef1dd..977f543ce98 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,5 +1,5 @@ """Support for Motion Blinds sensors.""" -from motionblinds import DEVICE_TYPES_WIFI, BlindType +from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, BlindType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -9,16 +9,13 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_AVAILABLE, DOMAIN, KEY_COORDINATOR, KEY_GATEWAY +from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY +from .entity import MotionCoordinatorEntity from .gateway import device_name ATTR_BATTERY_VOLTAGE = "battery_voltage" -TYPE_BLIND = "blind" -TYPE_GATEWAY = "gateway" async def async_setup_entry( @@ -32,7 +29,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for blind in motion_gateway.device_list.values(): - entities.append(MotionSignalStrengthSensor(coordinator, blind, TYPE_BLIND)) + entities.append(MotionSignalStrengthSensor(coordinator, blind)) if blind.type == BlindType.TopDownBottomUp: entities.append(MotionTDBUBatterySensor(coordinator, blind, "Bottom")) entities.append(MotionTDBUBatterySensor(coordinator, blind, "Top")) @@ -42,14 +39,12 @@ async def async_setup_entry( # Do not add signal sensor twice for direct WiFi blinds if motion_gateway.device_type not in DEVICE_TYPES_WIFI: - entities.append( - MotionSignalStrengthSensor(coordinator, motion_gateway, TYPE_GATEWAY) - ) + entities.append(MotionSignalStrengthSensor(coordinator, motion_gateway)) async_add_entities(entities) -class MotionBatterySensor(CoordinatorEntity, SensorEntity): +class MotionBatterySensor(MotionCoordinatorEntity, SensorEntity): """Representation of a Motion Battery Sensor.""" _attr_device_class = SensorDeviceClass.BATTERY @@ -57,24 +52,11 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, blind) - self._blind = blind - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, blind.mac)}) self._attr_name = f"{device_name(blind)} battery" self._attr_unique_id = f"{blind.mac}-battery" - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.coordinator.data is None: - return False - - if not self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE]: - return False - - return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] - @property def native_value(self): """Return the state of the sensor.""" @@ -85,16 +67,6 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): """Return device specific state attributes.""" return {ATTR_BATTERY_VOLTAGE: self._blind.battery_voltage} - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes.""" - self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._blind.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() - class MotionTDBUBatterySensor(MotionBatterySensor): """Representation of a Motion Battery Sensor for a Top Down Bottom Up blind.""" @@ -125,7 +97,7 @@ class MotionTDBUBatterySensor(MotionBatterySensor): return attributes -class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): +class MotionSignalStrengthSensor(MotionCoordinatorEntity, SensorEntity): """Representation of a Motion Signal Strength Sensor.""" _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH @@ -133,47 +105,19 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator, device, device_type): + def __init__(self, coordinator, blind): """Initialize the Motion Signal Strength Sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, blind) - if device_type == TYPE_GATEWAY: + if blind.device_type in DEVICE_TYPES_GATEWAY: name = "Motion gateway signal strength" else: - name = f"{device_name(device)} signal strength" + name = f"{device_name(blind)} signal strength" - self._device = device - self._device_type = device_type - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.mac)}) - self._attr_unique_id = f"{device.mac}-RSSI" + self._attr_unique_id = f"{blind.mac}-RSSI" self._attr_name = name - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.coordinator.data is None: - return False - - gateway_available = self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE] - if self._device_type == TYPE_GATEWAY: - return gateway_available - - return ( - gateway_available - and self.coordinator.data[self._device.mac][ATTR_AVAILABLE] - ) - @property def native_value(self): """Return the state of the sensor.""" - return self._device.RSSI - - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes.""" - self._device.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._device.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() + return self._blind.RSSI From cf8da2fc8930d540b899a577ca6098e1857b2a57 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 2 Sep 2023 22:13:17 +0200 Subject: [PATCH 069/984] Motion blinds add translations (#99078) --- .../components/motion_blinds/cover.py | 6 ++---- .../components/motion_blinds/entity.py | 2 ++ .../components/motion_blinds/sensor.py | 14 ++----------- .../components/motion_blinds/strings.json | 21 +++++++++++++++++++ 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 1a4507f1066..833d2640202 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -33,7 +33,6 @@ from .const import ( UPDATE_INTERVAL_MOVING_WIFI, ) from .entity import MotionCoordinatorEntity -from .gateway import device_name _LOGGER = logging.getLogger(__name__) @@ -170,6 +169,7 @@ async def async_setup_entry( class MotionPositionDevice(MotionCoordinatorEntity, CoverEntity): """Representation of a Motion Blind Device.""" + _attr_name = None _restore_tilt = False def __init__(self, coordinator, blind, device_class): @@ -184,9 +184,7 @@ class MotionPositionDevice(MotionCoordinatorEntity, CoverEntity): else: self._update_interval_moving = UPDATE_INTERVAL_MOVING - name = device_name(blind) self._attr_device_class = device_class - self._attr_name = name self._attr_unique_id = blind.mac @property @@ -402,7 +400,7 @@ class MotionTDBUDevice(MotionPositionDevice): super().__init__(coordinator, blind, device_class) self._motor = motor self._motor_key = motor[0] - self._attr_name = f"{device_name(blind)} {motor}" + self._attr_translation_key = motor.lower() self._attr_unique_id = f"{blind.mac}-{motor}" if self._motor not in ["Bottom", "Top", "Combined"]: diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index d57d7401b47..8f3ac05228d 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -22,6 +22,8 @@ from .gateway import device_name class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]): """Representation of a Motion Blind entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinatorMotionBlinds, diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 977f543ce98..d8dc25e0006 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,5 +1,5 @@ """Support for Motion Blinds sensors.""" -from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, BlindType +from motionblinds import DEVICE_TYPES_WIFI, BlindType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY from .entity import MotionCoordinatorEntity -from .gateway import device_name ATTR_BATTERY_VOLTAGE = "battery_voltage" @@ -53,8 +52,6 @@ class MotionBatterySensor(MotionCoordinatorEntity, SensorEntity): def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" super().__init__(coordinator, blind) - - self._attr_name = f"{device_name(blind)} battery" self._attr_unique_id = f"{blind.mac}-battery" @property @@ -77,7 +74,7 @@ class MotionTDBUBatterySensor(MotionBatterySensor): self._motor = motor self._attr_unique_id = f"{blind.mac}-{motor}-battery" - self._attr_name = f"{device_name(blind)} {motor} battery" + self._attr_translation_key = f"{motor.lower()}_battery" @property def native_value(self): @@ -108,14 +105,7 @@ class MotionSignalStrengthSensor(MotionCoordinatorEntity, SensorEntity): def __init__(self, coordinator, blind): """Initialize the Motion Signal Strength Sensor.""" super().__init__(coordinator, blind) - - if blind.device_type in DEVICE_TYPES_GATEWAY: - name = "Motion gateway signal strength" - else: - name = f"{device_name(blind)} signal strength" - self._attr_unique_id = f"{blind.mac}-RSSI" - self._attr_name = name @property def native_value(self): diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index 0e0a32bfb24..cb9468c3a27 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -60,5 +60,26 @@ } } } + }, + "entity": { + "cover": { + "top": { + "name": "Top" + }, + "bottom": { + "name": "Bottom" + }, + "combined": { + "name": "Combined" + } + }, + "sensor": { + "top_battery": { + "name": "Top battery" + }, + "bottom_battery": { + "name": "Bottom battery" + } + } } } From f4f78cf00076af95be427cf6f6ddbcc8fefcb259 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 15:25:42 -0500 Subject: [PATCH 070/984] Bump aiohomekit to 3.0.2 (#99514) --- homeassistant/components/homekit_controller/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_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 83852f38d52..9567ff83cea 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.1"], + "requirements": ["aiohomekit==3.0.2"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 9898a0b8f46..236f6bec494 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.1 +aiohomekit==3.0.2 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2a3bb2e718..1c31e416a30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.1 +aiohomekit==3.0.2 # homeassistant.components.emulated_hue # homeassistant.components.http From bec36d39145a5e0a81b37196bf5f6b34891e9509 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sat, 2 Sep 2023 23:44:28 +0200 Subject: [PATCH 071/984] Add long-term statistics to BMW sensors (#99506) * Add long-term statistics to BMW ConnectedDrive sensors * Add sensor test snapshot --------- Co-authored-by: rikroe --- .../components/bmw_connected_drive/sensor.py | 8 + .../snapshots/test_sensor.ambr | 396 ++++++++++++++++++ .../bmw_connected_drive/test_sensor.py | 18 + 3 files changed, 422 insertions(+) create mode 100644 tests/components/bmw_connected_drive/snapshots/test_sensor.ambr diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 8f5b4fb8608..62854badb20 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent @@ -94,6 +95,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { key_class="fuel_and_battery", unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, ), # --- Specific --- "mileage": BMWSensorEntityDescription( @@ -102,6 +104,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:speedometer", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.TOTAL_INCREASING, ), "remaining_range_total": BMWSensorEntityDescription( key="remaining_range_total", @@ -110,6 +113,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_range_electric": BMWSensorEntityDescription( key="remaining_range_electric", @@ -118,6 +122,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_range_fuel": BMWSensorEntityDescription( key="remaining_range_fuel", @@ -126,6 +131,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_fuel": BMWSensorEntityDescription( key="remaining_fuel", @@ -134,6 +140,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:gas-station", unit_type=VOLUME, value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_fuel_percent": BMWSensorEntityDescription( key="remaining_fuel_percent", @@ -141,6 +148,7 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { key_class="fuel_and_battery", icon="mdi:gas-station", unit_type=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, ), } diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..d64bdb32597 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -0,0 +1,396 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '340', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'iX xDrive50 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '70', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '340', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '472', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i4 eDrive40 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'NOT_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '472', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '629', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', + 'last_changed': , + 'last_updated': , + 'state': '40', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining range fuel', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', + 'last_changed': , + 'last_updated': , + 'state': '629', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel percent', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '279', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_mileage', + 'last_changed': , + 'last_updated': , + 'state': '137009', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i3 (+ REX) Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'WAITING_FOR_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i3 (+ REX) Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '82', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '174', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel', + 'last_changed': , + 'last_updated': , + 'state': '6', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range fuel', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_fuel', + 'last_changed': , + 'last_updated': , + 'state': '105', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel percent', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index 95b1145d9d6..c6cb12cf047 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -1,5 +1,8 @@ """Test BMW sensors.""" +from freezegun import freeze_time import pytest +import respx +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.util.unit_system import ( @@ -11,6 +14,21 @@ from homeassistant.util.unit_system import ( from . import setup_mocked_integration +@freeze_time("2023-06-22 10:30:00+00:00") +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all select entities + assert hass.states.async_all("sensor") == snapshot + + @pytest.mark.parametrize( ("entity_id", "unit_system", "value", "unit_of_measurement"), [ From b8f8cd17972b815ca0c1901cc818737e44269e44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 16:46:53 -0500 Subject: [PATCH 072/984] Reduce overhead to retry config entry setup (#99471) --- homeassistant/config_entries.py | 76 ++++++++++++++++++++++----------- tests/test_config_entries.py | 2 +- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a3b03407a14..7900c6b62a4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -148,6 +148,11 @@ EVENT_FLOW_DISCOVERED = "config_entry_discovered" SIGNAL_CONFIG_ENTRY_CHANGED = "config_entry_changed" +NO_RESET_TRIES_STATES = { + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, +} + class ConfigEntryChange(StrEnum): """What was changed in a config entry.""" @@ -220,6 +225,9 @@ class ConfigEntry: "reload_lock", "_tasks", "_background_tasks", + "_integration_for_domain", + "_tries", + "_setup_again_job", ) def __init__( @@ -317,12 +325,15 @@ class ConfigEntry: self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() + self._integration_for_domain: loader.Integration | None = None + self._tries = 0 + self._setup_again_job: HassJob | None = None + async def async_setup( self, hass: HomeAssistant, *, integration: loader.Integration | None = None, - tries: int = 0, ) -> None: """Set up an entry.""" current_entry.set(self) @@ -331,6 +342,7 @@ class ConfigEntry: if integration is None: integration = await loader.async_get_integration(hass, self.domain) + self._integration_for_domain = integration # Only store setup result as state if it was not forwarded. if self.domain == integration.domain: @@ -419,13 +431,13 @@ class ConfigEntry: result = False except ConfigEntryNotReady as ex: self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(ex) or None) - wait_time = 2 ** min(tries, 4) * 5 + ( + wait_time = 2 ** min(self._tries, 4) * 5 + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) - tries += 1 + self._tries += 1 message = str(ex) ready_message = f"ready yet: {message}" if message else "ready yet" - if tries == 1: + if self._tries == 1: _LOGGER.warning( ( "Config entry '%s' for %s integration not %s; Retrying in" @@ -447,22 +459,14 @@ class ConfigEntry: wait_time, ) - async def setup_again(*_: Any) -> None: - """Run setup again.""" - # Check again when we fire in case shutdown - # has started so we do not block shutdown - if hass.is_stopping: - return - self._async_cancel_retry_setup = None - await self.async_setup(hass, integration=integration, tries=tries) - if hass.state == CoreState.running: self._async_cancel_retry_setup = async_call_later( - hass, wait_time, setup_again + hass, wait_time, self._async_get_setup_again_job(hass) ) else: self._async_cancel_retry_setup = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, setup_again + EVENT_HOMEASSISTANT_STARTED, + functools.partial(self._async_setup_again, hass), ) await self._async_process_on_unload(hass) @@ -483,6 +487,24 @@ class ConfigEntry: else: self._async_set_state(hass, ConfigEntryState.SETUP_ERROR, error_reason) + async def _async_setup_again(self, hass: HomeAssistant, *_: Any) -> None: + """Run setup again.""" + # Check again when we fire in case shutdown + # has started so we do not block shutdown + if not hass.is_stopping: + self._async_cancel_retry_setup = None + await self.async_setup(hass) + + @callback + def _async_get_setup_again_job(self, hass: HomeAssistant) -> HassJob: + """Get a job that will call setup again.""" + if not self._setup_again_job: + self._setup_again_job = HassJob( + functools.partial(self._async_setup_again, hass), + cancel_on_shutdown=True, + ) + return self._setup_again_job + async def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" self.async_cancel_retry_setup() @@ -508,7 +530,7 @@ class ConfigEntry: if self.state == ConfigEntryState.NOT_LOADED: return True - if integration is None: + if not integration and (integration := self._integration_for_domain) is None: try: integration = await loader.async_get_integration(hass, self.domain) except loader.IntegrationNotFound: @@ -566,14 +588,15 @@ class ConfigEntry: if self.source == SOURCE_IGNORE: return - try: - integration = await loader.async_get_integration(hass, self.domain) - except loader.IntegrationNotFound: - # The integration was likely a custom_component - # that was uninstalled, or an integration - # that has been renamed without removing the config - # entry. - return + if not (integration := self._integration_for_domain): + try: + integration = await loader.async_get_integration(hass, self.domain) + except loader.IntegrationNotFound: + # The integration was likely a custom_component + # that was uninstalled, or an integration + # that has been renamed without removing the config + # entry. + return component = integration.get_component() if not hasattr(component, "async_remove_entry"): @@ -592,6 +615,8 @@ class ConfigEntry: self, hass: HomeAssistant, state: ConfigEntryState, reason: str | None ) -> None: """Set the state of the config entry.""" + if state not in NO_RESET_TRIES_STATES: + self._tries = 0 self.state = state self.reason = reason async_dispatcher_send( @@ -617,7 +642,8 @@ class ConfigEntry: if self.version == handler.VERSION: return True - integration = await loader.async_get_integration(hass, self.domain) + if not (integration := self._integration_for_domain): + integration = await loader.async_get_integration(hass, self.domain) component = integration.get_component() supports_migrate = hasattr(component, "async_migrate_entry") if not supports_migrate: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 760c7138c88..52caa1ae275 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -960,7 +960,7 @@ async def test_setup_raise_not_ready( mock_setup_entry.side_effect = None mock_setup_entry.return_value = True - await p_setup(None) + await hass.async_run_hass_job(p_setup, None) assert entry.state is config_entries.ConfigEntryState.LOADED assert entry.reason is None From 4b11a632a133f49f6e521d99d7570b7fd0bcf30a Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sat, 2 Sep 2023 23:45:46 +0100 Subject: [PATCH 073/984] Add sensors to private_ble_device (#99515) --- .../components/private_ble_device/__init__.py | 2 +- .../components/private_ble_device/entity.py | 5 +- .../components/private_ble_device/sensor.py | 126 ++++++++++++++++++ .../private_ble_device/strings.json | 10 ++ .../private_ble_device/test_sensor.py | 47 +++++++ 5 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/private_ble_device/sensor.py create mode 100644 tests/components/private_ble_device/test_sensor.py diff --git a/homeassistant/components/private_ble_device/__init__.py b/homeassistant/components/private_ble_device/__init__.py index c4666ccc02f..dcb6555bbc9 100644 --- a/homeassistant/components/private_ble_device/__init__.py +++ b/homeassistant/components/private_ble_device/__init__.py @@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/private_ble_device/entity.py b/homeassistant/components/private_ble_device/entity.py index ae632213506..978313e9671 100644 --- a/homeassistant/components/private_ble_device/entity.py +++ b/homeassistant/components/private_ble_device/entity.py @@ -24,7 +24,10 @@ class BasePrivateDeviceEntity(Entity): """Set up a new BleScanner entity.""" irk = config_entry.data["irk"] - self._attr_unique_id = irk + if self.translation_key: + self._attr_unique_id = f"{irk}_{self.translation_key}" + else: + self._attr_unique_id = irk self._attr_device_info = DeviceInfo( name=f"Private BLE Device {irk[:6]}", diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py new file mode 100644 index 00000000000..c2ec4ca39ce --- /dev/null +++ b/homeassistant/components/private_ble_device/sensor.py @@ -0,0 +1,126 @@ +"""Support for iBeacon device sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bluetooth_data_tools import calculate_distance_meters + +from homeassistant.components import bluetooth +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfLength, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import BasePrivateDeviceEntity + + +@dataclass +class PrivateDeviceSensorEntityDescriptionRequired: + """Required domain specific fields for sensor entity.""" + + value_fn: Callable[[bluetooth.BluetoothServiceInfoBleak], str | int | float | None] + + +@dataclass +class PrivateDeviceSensorEntityDescription( + SensorEntityDescription, PrivateDeviceSensorEntityDescriptionRequired +): + """Describes sensor entity.""" + + +SENSOR_DESCRIPTIONS = ( + PrivateDeviceSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda service_info: service_info.advertisement.rssi, + state_class=SensorStateClass.MEASUREMENT, + ), + PrivateDeviceSensorEntityDescription( + key="power", + translation_key="power", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda service_info: service_info.advertisement.tx_power, + state_class=SensorStateClass.MEASUREMENT, + ), + PrivateDeviceSensorEntityDescription( + key="estimated_distance", + translation_key="estimated_distance", + icon="mdi:signal-distance-variant", + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda service_info: service_info.advertisement + and service_info.advertisement.tx_power + and calculate_distance_meters( + service_info.advertisement.tx_power * 10, service_info.advertisement.rssi + ), + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensors for Private BLE component.""" + async_add_entities( + PrivateBLEDeviceSensor(entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PrivateBLEDeviceSensor(BasePrivateDeviceEntity, SensorEntity): + """A sensor entity.""" + + entity_description: PrivateDeviceSensorEntityDescription + + def __init__( + self, + config_entry: ConfigEntry, + entity_description: PrivateDeviceSensorEntityDescription, + ) -> None: + """Initialize an sensor entity.""" + self.entity_description = entity_description + self._attr_available = False + super().__init__(config_entry) + + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update state.""" + self._attr_available = True + self._last_info = service_info + self.async_write_ha_state() + + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Update state.""" + self._attr_available = False + self.async_write_ha_state() + + @property + def native_value(self) -> str | int | float | None: + """Return the state of the sensor.""" + assert self._last_info + return self.entity_description.value_fn(self._last_info) diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index c62ea5c4d50..279ff38bc9b 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -16,5 +16,15 @@ "abort": { "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." } + }, + "entity": { + "sensor": { + "power": { + "name": "Power" + }, + "estimated_distance": { + "name": "Estimated distance" + } + } } } diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py new file mode 100644 index 00000000000..820ec2199ad --- /dev/null +++ b/tests/components/private_ble_device/test_sensor.py @@ -0,0 +1,47 @@ +"""Tests for sensors.""" + + +from homeassistant.core import HomeAssistant + +from . import MAC_RPA_VALID_1, async_inject_broadcast, async_mock_config_entry + + +async def test_sensor_unavailable( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors are unavailable.""" + await async_mock_config_entry(hass) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "unavailable" + + +async def test_sensors_already_home( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we start at home.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "-63" + + +async def test_sensors_come_home( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we receive a broadcast.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "-63" From 6312f345381040d27b855bfeffc59fff060daf07 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 3 Sep 2023 01:07:17 +0200 Subject: [PATCH 074/984] Fix zha test RuntimeWarning (#99519) --- tests/components/zha/test_config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 77d8a615c72..d97a0de0d58 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -10,6 +10,7 @@ import serial.tools.list_ports from zigpy.backups import BackupManager import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +import zigpy.device from zigpy.exceptions import NetworkNotFormed import zigpy.types @@ -1181,6 +1182,7 @@ async def test_onboarding_auto_formation_new_hardware( ) -> None: """Test auto network formation with new hardware during onboarding.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) + mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device)) discovery_info = usb.UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", From 7b1c0c2df20fa23281a05f224a1cd6c0b029d6ca Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Sep 2023 16:19:45 -0700 Subject: [PATCH 075/984] Extend template entities with a script section (#96175) * Extend template entities with a script section This allows making a trigger entity that triggers a few times a day, and allows collecting data from a service resopnse which can be fed into a template entity. The current alternatives are to publish and subscribe to events or to store data in input entities. * Make variables set in actions accessible to templates * Format code --------- Co-authored-by: Erik --- homeassistant/components/script/__init__.py | 3 +- homeassistant/components/template/__init__.py | 19 ++++++-- homeassistant/components/template/config.py | 3 +- homeassistant/components/template/const.py | 1 + .../components/websocket_api/commands.py | 4 +- homeassistant/helpers/script.py | 15 +++++-- tests/components/template/test_sensor.py | 44 +++++++++++++++++++ 7 files changed, 79 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 8530aa3b04c..13b25a00053 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -563,7 +563,8 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): ) coro = self._async_run(variables, context) if wait: - return await coro + script_result = await coro + return script_result.service_response if script_result else None # Caller does not want to wait for called script to finish so let script run in # separate Task. Make a new empty script stack; scripts are allowed to diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index e9ced060491..c4ba7081f5a 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -20,11 +20,12 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.script import Script from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration -from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -133,6 +134,7 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): self.config = config self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None + self._script: Script | None = None @property def unique_id(self) -> str | None: @@ -170,6 +172,14 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): async def _attach_triggers(self, start_event=None) -> None: """Attach the triggers.""" + if CONF_ACTION in self.config: + self._script = Script( + self.hass, + self.config[CONF_ACTION], + self.name, + DOMAIN, + ) + if start_event is not None: self._unsub_start = None @@ -183,8 +193,11 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): start_event is not None, ) - @callback - def _handle_triggered(self, run_variables, context=None): + async def _handle_triggered(self, run_variables, context=None): + if self._script: + script_result = await self._script.async_run(run_variables, context) + if script_result: + run_variables = script_result.variables self.async_set_updated_data( {"run_variables": run_variables, "context": context} ) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 2261bde2659..54c82d88c74 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -22,7 +22,7 @@ from . import ( select as select_platform, sensor as sensor_platform, ) -from .const import CONF_TRIGGER, DOMAIN +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN PACKAGE_MERGE_HINT = "list" @@ -30,6 +30,7 @@ CONFIG_SECTION_SCHEMA = vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(NUMBER_DOMAIN): vol.All( cv.ensure_list, [number_platform.NUMBER_SCHEMA] ), diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 9b371125750..6805c0ad812 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -2,6 +2,7 @@ from homeassistant.const import Platform +CONF_ACTION = "action" CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_TRIGGER = "trigger" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index bbcbfa6ecb8..c6564967a39 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -713,12 +713,12 @@ async def handle_execute_script( context = connection.context(msg) script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN) - response = await script_obj.async_run(msg.get("variables"), context=context) + script_result = await script_obj.async_run(msg.get("variables"), context=context) connection.send_result( msg["id"], { "context": context, - "response": response, + "response": script_result.service_response if script_result else None, }, ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 4035d55b325..c9d8de23b96 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Mapping, Sequence from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from copy import copy +from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial import itertools @@ -401,7 +402,7 @@ class _ScriptRun: ) self._log("Executing step %s%s", self._script.last_action, _timeout) - async def async_run(self) -> ServiceResponse: + async def async_run(self) -> ScriptRunResult | None: """Run script.""" # Push the script to the script execution stack if (script_stack := script_stack_cv.get()) is None: @@ -443,7 +444,7 @@ class _ScriptRun: script_stack.pop() self._finish() - return response + return ScriptRunResult(response, self._variables) async def _async_step(self, log_exceptions): continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) @@ -1189,6 +1190,14 @@ class _IfData(TypedDict): if_else: Script | None +@dataclass +class ScriptRunResult: + """Container with the result of a script run.""" + + service_response: ServiceResponse + variables: dict + + class Script: """Representation of a script.""" @@ -1480,7 +1489,7 @@ class Script: run_variables: _VarsType | None = None, context: Context | None = None, started_action: Callable[..., Any] | None = None, - ) -> ServiceResponse: + ) -> ScriptRunResult | None: """Run script.""" if context is None: self._log( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 47e307bc6aa..cf9f3724020 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1582,3 +1582,47 @@ async def test_trigger_entity_restore_state( assert state.attributes["entity_picture"] == "/local/dogs.png" assert state.attributes["plus_one"] == 3 assert state.attributes["another"] == 1 + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.beer + 1 }}" + }, + }, + ], + "sensor": [ + { + "name": "Hello Name", + "state": "{{ my_variable + 1 }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_action( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity with an action works.""" + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == "3" + assert state.context is context From 61dc217d92055a52ea75094c37621ed3a725f7e3 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 3 Sep 2023 10:25:00 +0200 Subject: [PATCH 076/984] Bump gardena_bluetooth to 1.4.0 (#99530) --- homeassistant/components/gardena_bluetooth/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 5d1c1888586..3e07eb1ad42 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena_bluetooth==1.3.0"] + "requirements": ["gardena_bluetooth==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 236f6bec494..7945efdf93c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -837,7 +837,7 @@ fritzconnection[qr]==1.12.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.3.0 +gardena_bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c31e416a30..823b5d36dec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -656,7 +656,7 @@ fritzconnection[qr]==1.12.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.3.0 +gardena_bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 From 1183bd159e898ab6cd09f6daf93cbebd7098b571 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 04:16:26 -0500 Subject: [PATCH 077/984] Bump zeroconf to 0.93.1 (#99516) * Bump zeroconf to 0.92.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.91.1...0.92.0 * drop unused argument * Update tests/components/thread/test_diagnostics.py * lint * again * bump again since actions failed to release the wheels --- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/thread/test_diagnostics.py | 44 +++++++++---------- 5 files changed, 25 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 26577bd0bbe..718f3047a07 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.91.1"] + "requirements": ["zeroconf==0.93.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8069d5c0e70..bd9125c59fe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.91.1 +zeroconf==0.93.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 7945efdf93c..32d04870543 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.91.1 +zeroconf==0.93.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 823b5d36dec..12fda706d08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.91.1 +zeroconf==0.93.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/tests/components/thread/test_diagnostics.py b/tests/components/thread/test_diagnostics.py index 94ca4373715..15ab0750316 100644 --- a/tests/components/thread/test_diagnostics.py +++ b/tests/components/thread/test_diagnostics.py @@ -1,7 +1,6 @@ """Test the thread websocket API.""" import dataclasses -import time from unittest.mock import Mock, patch import pytest @@ -191,50 +190,49 @@ async def test_diagnostics( """Test diagnostics for thread routers.""" cache = mock_async_zeroconf.zeroconf.cache = DNSCache() - now = time.monotonic() * 1000 cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_1.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_1.dns_service(created=now), - TEST_ZEROCONF_RECORD_1.dns_text(created=now), - TEST_ZEROCONF_RECORD_1.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_1.dns_addresses(), + TEST_ZEROCONF_RECORD_1.dns_service(), + TEST_ZEROCONF_RECORD_1.dns_text(), + TEST_ZEROCONF_RECORD_1.dns_pointer(), ] ) cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_2.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_2.dns_service(created=now), - TEST_ZEROCONF_RECORD_2.dns_text(created=now), - TEST_ZEROCONF_RECORD_2.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_2.dns_addresses(), + TEST_ZEROCONF_RECORD_2.dns_service(), + TEST_ZEROCONF_RECORD_2.dns_text(), + TEST_ZEROCONF_RECORD_2.dns_pointer(), ] ) # Test for invalid cache - cache.async_add_records([TEST_ZEROCONF_RECORD_3.dns_pointer(created=now)]) + cache.async_add_records([TEST_ZEROCONF_RECORD_3.dns_pointer()]) # Test for invalid record cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_4.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_4.dns_service(created=now), - TEST_ZEROCONF_RECORD_4.dns_text(created=now), - TEST_ZEROCONF_RECORD_4.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_4.dns_addresses(), + TEST_ZEROCONF_RECORD_4.dns_service(), + TEST_ZEROCONF_RECORD_4.dns_text(), + TEST_ZEROCONF_RECORD_4.dns_pointer(), ] ) # Test for record without xa cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_5.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_5.dns_service(created=now), - TEST_ZEROCONF_RECORD_5.dns_text(created=now), - TEST_ZEROCONF_RECORD_5.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_5.dns_addresses(), + TEST_ZEROCONF_RECORD_5.dns_service(), + TEST_ZEROCONF_RECORD_5.dns_text(), + TEST_ZEROCONF_RECORD_5.dns_pointer(), ] ) # Test for record without xp cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_6.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_6.dns_service(created=now), - TEST_ZEROCONF_RECORD_6.dns_text(created=now), - TEST_ZEROCONF_RECORD_6.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_6.dns_addresses(), + TEST_ZEROCONF_RECORD_6.dns_service(), + TEST_ZEROCONF_RECORD_6.dns_text(), + TEST_ZEROCONF_RECORD_6.dns_pointer(), ] ) assert await async_setup_component(hass, DOMAIN, {}) From 6414248bee1ab9e9491751b3dd0a6081db8a46a0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 3 Sep 2023 13:04:01 +0200 Subject: [PATCH 078/984] Update pytest warning filter (#99521) --- pyproject.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8f5b5c788fa..e535e7bbc7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -449,6 +449,12 @@ filterwarnings = [ # -- tracked upstream / open PRs # https://github.com/caronc/apprise/issues/659 - v1.4.5 "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", + # https://github.com/gwww/elkm1/pull/71 - v2.2.5 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:elkm1_lib.util", + # https://github.com/poljar/matrix-nio/pull/438 - v0.21.2 + "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", + # https://github.com/poljar/matrix-nio/pull/439 - v0.21.2 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 "ignore:'imghdr' is deprecated and slated for removal in Python 3.13:DeprecationWarning:mediafile", # https://github.com/eclipse/paho.mqtt.python/issues/653 - v1.6.1 @@ -465,6 +471,10 @@ filterwarnings = [ "ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning:xdist.plugin", # -- fixed, waiting for release / update + # https://github.com/kurtmckee/feedparser/issues/330 - >6.0.10 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", + # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 + "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", # https://github.com/gurumitts/pylutron-caseta/pull/143 - >0.18.1 "ignore:ssl.PROTOCOL_TLSv1_2 is deprecated:DeprecationWarning:pylutron_caseta.smartbridge", # https://github.com/Danielhiversen/pyMillLocal/pull/8 - >=0.3.0 @@ -474,6 +484,11 @@ filterwarnings = [ # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 "ignore:The module pyatmo.* is deprecated:DeprecationWarning:pyatmo", + # -- other + # Locale changes might take some time to resolve upstream + "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:homematicip.base.base_connection", + "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:micloud.micloud", + # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", From 1cd0cb4537a420a6572583f6eb163318ddba340a Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sun, 3 Sep 2023 13:39:20 +0100 Subject: [PATCH 079/984] Add suggest_display_precision to private_ble_device (#99529) --- homeassistant/components/private_ble_device/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index c2ec4ca39ce..e2f5efb6699 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -71,6 +71,7 @@ SENSOR_DESCRIPTIONS = ( ), state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, ), ) From 5f487b5d8582670021479f70922d9427302b10a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 08:19:06 -0500 Subject: [PATCH 080/984] Refactor async_call_at and async_call_later event helpers to avoid creating closures (#99469) --- homeassistant/helpers/event.py | 40 +++++++++------------------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 62a3b91991d..b8831d38d86 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1432,6 +1432,13 @@ def async_track_point_in_utc_time( track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_time) +def _run_async_call_action( + hass: HomeAssistant, job: HassJob[[datetime], Coroutine[Any, Any, None] | None] +) -> None: + """Run action.""" + hass.async_run_hass_job(job, time_tracker_utcnow()) + + @callback @bind_hass def async_call_at( @@ -1441,26 +1448,12 @@ def async_call_at( loop_time: float, ) -> CALLBACK_TYPE: """Add a listener that is called at .""" - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - hass.async_run_hass_job(job, time_tracker_utcnow()) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"call_at {loop_time}") ) - cancel_callback = hass.loop.call_at(loop_time, run_action, job) - - @callback - def unsub_call_later_listener() -> None: - """Cancel the call_later.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_call_later_listener + return hass.loop.call_at(loop_time, _run_async_call_action, hass, job).cancel @callback @@ -1474,26 +1467,13 @@ def async_call_later( """Add a listener that is called in .""" if isinstance(delay, timedelta): delay = delay.total_seconds() - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - hass.async_run_hass_job(job, time_tracker_utcnow()) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"call_later {delay}") ) - cancel_callback = hass.loop.call_at(hass.loop.time() + delay, run_action, job) - - @callback - def unsub_call_later_listener() -> None: - """Cancel the call_later.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_call_later_listener + loop = hass.loop + return loop.call_at(loop.time() + delay, _run_async_call_action, hass, job).cancel call_later = threaded_listener_factory(async_call_later) From 00893bbf14ac2b2e9c1dfc739409fff95d890604 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 08:22:03 -0500 Subject: [PATCH 081/984] Bump bleak to 0.21.0 (#99520) Co-authored-by: Martin Hjelmare --- .../components/bluetooth/manifest.json | 2 +- homeassistant/components/bluetooth/wrappers.py | 6 ++++-- .../components/esphome/bluetooth/client.py | 18 +++++++++++------- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 54c8a52e24b..8dd87b8361d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -14,7 +14,7 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.2", + "bleak==0.21.0", "bleak-retry-connector==3.1.1", "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 3a0abc855b5..97f253f8825 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -120,15 +120,17 @@ class HaBleakScannerWrapper(BaseBleakScanner): def register_detection_callback( self, callback: AdvertisementDataCallback | None - ) -> None: + ) -> Callable[[], None]: """Register a detection callback. The callback is called when a device is discovered or has a property changed. - This method takes the callback and registers it with the long running sscanner. + This method takes the callback and registers it with the long running scanner. """ self._advertisement_data_callback = callback self._setup_detection_callback() + assert self._detection_cancel is not None + return self._detection_cancel def _setup_detection_callback(self) -> None: """Set up the detection callback.""" diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index ad43ca5df7d..411a5b989a3 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -7,9 +7,15 @@ import contextlib from dataclasses import dataclass, field from functools import partial import logging +import sys from typing import Any, TypeVar, cast import uuid +if sys.version_info < (3, 12): + from typing_extensions import Buffer +else: + from collections.abc import Buffer + from aioesphomeapi import ( ESP_CONNECTION_ERROR_DESCRIPTION, ESPHOME_GATT_ERRORS, @@ -620,14 +626,14 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def write_gatt_char( self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - data: bytes | bytearray | memoryview, + characteristic: BleakGATTCharacteristic | int | str | uuid.UUID, + data: Buffer, response: bool = False, ) -> None: """Perform a write operation of the specified GATT characteristic. Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): + characteristic (BleakGATTCharacteristic, int, str or UUID): The characteristic to write to, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. @@ -635,16 +641,14 @@ class ESPHomeClient(BaseBleakClient): response (bool): If write-with-response operation should be done. Defaults to `False`. """ - characteristic = self._resolve_characteristic(char_specifier) + characteristic = self._resolve_characteristic(characteristic) await self._client.bluetooth_gatt_write( self._address_as_int, characteristic.handle, bytes(data), response ) @verify_connected @api_error_as_bleak_error - async def write_gatt_descriptor( - self, handle: int, data: bytes | bytearray | memoryview - ) -> None: + async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: """Perform a write operation on the specified GATT descriptor. Args: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd9125c59fe..8077c6e5712 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -9,7 +9,7 @@ attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.1.1 -bleak==0.20.2 +bleak==0.21.0 bluetooth-adapters==0.16.0 bluetooth-auto-recovery==1.2.1 bluetooth-data-tools==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index 32d04870543..8ae3ed3db0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.1.1 # homeassistant.components.bluetooth -bleak==0.20.2 +bleak==0.21.0 # homeassistant.components.blebox blebox-uniapi==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12fda706d08..eaa6a84ad89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,7 +440,7 @@ bimmer-connected==0.14.0 bleak-retry-connector==3.1.1 # homeassistant.components.bluetooth -bleak==0.20.2 +bleak==0.21.0 # homeassistant.components.blebox blebox-uniapi==2.1.4 From 31d1752c74838fdf4db72276fe81572b3510419f Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 4 Sep 2023 01:53:23 +1200 Subject: [PATCH 082/984] Bugfix: Electric Kiwi reduce interval so oauth doesn't expire (#99489) decrease interval time as EK have broken/changed their oauth again --- homeassistant/components/electric_kiwi/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 49611f9febd..b084f4656d5 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -14,7 +14,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) -HOP_SCAN_INTERVAL = timedelta(hours=2) +HOP_SCAN_INTERVAL = timedelta(minutes=20) class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): From 9bba501057dcbe61810036d9c380f6a7d701c893 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 3 Sep 2023 09:54:00 -0400 Subject: [PATCH 083/984] Handle gracefully when unloading apcupsd config entries (#99513) --- homeassistant/components/apcupsd/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 164a908e834..8d7c6b2f46d 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -48,7 +48,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b 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) - hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok and DOMAIN in hass.data: + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok From 8843a445c9fd4d5eed1b8c1fc3134634f7dc0714 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 08:59:15 -0500 Subject: [PATCH 084/984] Reduce Bluetooth coordinator/processor overhead (#99526) --- homeassistant/components/bluetooth/active_update_coordinator.py | 2 +- homeassistant/components/bluetooth/active_update_processor.py | 2 +- homeassistant/components/bluetooth/passive_update_processor.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 5fa05b87cc8..cdf51d34978 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -110,7 +110,7 @@ class ActiveBluetoothDataUpdateCoordinator( return False poll_age: float | None = None if self._last_poll: - poll_age = monotonic_time_coarse() - self._last_poll + poll_age = service_info.time - self._last_poll return self._needs_poll_method(service_info, poll_age) async def _async_poll_data( diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index 8e38191c820..a3f5e20a9e9 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -103,7 +103,7 @@ class ActiveBluetoothProcessorCoordinator( return False poll_age: float | None = None if self._last_poll: - poll_age = monotonic_time_coarse() - self._last_poll + poll_age = service_info.time - self._last_poll return self._needs_poll_method(service_info, poll_age) async def _async_poll_data( diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 20b992d06d6..6d0621fa4f6 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -341,7 +341,6 @@ class PassiveBluetoothProcessorCoordinator( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" - super()._async_handle_bluetooth_event(service_info, change) if self.hass.is_stopping: return From 8e22041ee96c65137034a78c36fc748f8c389fb6 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sun, 3 Sep 2023 17:12:37 +0300 Subject: [PATCH 085/984] Change calculation methods to a fixed list (#99535) --- .../islamic_prayer_times/config_flow.py | 13 ++++++++++- .../components/islamic_prayer_times/const.py | 21 +++++++++++++++--- .../islamic_prayer_times/strings.json | 22 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index d0d314fe67d..597d67c19f4 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -8,6 +8,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME @@ -58,7 +63,13 @@ class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): default=self.config_entry.options.get( CONF_CALC_METHOD, DEFAULT_CALC_METHOD ), - ): vol.In(CALC_METHODS) + ): SelectSelector( + SelectSelectorConfig( + options=CALC_METHODS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_CALC_METHOD, + ) + ), } return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index 2a73a33bef8..67fac6c9261 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -1,12 +1,27 @@ """Constants for the Islamic Prayer component.""" from typing import Final -from prayer_times_calculator import PrayerTimesCalculator - DOMAIN: Final = "islamic_prayer_times" NAME: Final = "Islamic Prayer Times" CONF_CALC_METHOD: Final = "calculation_method" -CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS) +CALC_METHODS: Final = [ + "jafari", + "karachi", + "isna", + "mwl", + "makkah", + "egypt", + "tehran", + "gulf", + "kuwait", + "qatar", + "singapore", + "france", + "turkey", + "russia", + "moonsighting", + "custom", +] DEFAULT_CALC_METHOD: Final = "isna" diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index 7c09cc605bd..d02b26ec533 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -20,6 +20,28 @@ } } }, + "selector": { + "calculation_method": { + "options": { + "jafari": "Shia Ithna-Ansari", + "karachi": "University of Islamic Sciences, Karachi", + "isna": "Islamic Society of North America", + "mwl": "Muslim World League", + "makkah": "Umm Al-Qura University, Makkah", + "egypt": "Egyptian General Authority of Survey", + "tehran": "Institute of Geophysics, University of Tehran", + "gulf": "Gulf Region", + "kuwait": "Kuwait", + "qatar": "Qatar", + "singapore": "Majlis Ugama Islam Singapura, Singapore", + "france": "Union Organization islamic de France", + "turkey": "Diyanet İşleri Başkanlığı, Turkey", + "russia": "Spiritual Administration of Muslims of Russia", + "moonsighting": "Moonsighting Committee Worldwide", + "custom": "Custom" + } + } + }, "entity": { "sensor": { "fajr": { From d063650fec79eff739631f0f0cc3e64b9577ce24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 09:13:21 -0500 Subject: [PATCH 086/984] Bump bleak-retry-connector to 3.1.2 (#99540) --- 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 8dd87b8361d..e1a5ee41324 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.0", - "bleak-retry-connector==3.1.1", + "bleak-retry-connector==3.1.2", "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.11.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8077c6e5712..c1b5f5a947f 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==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.1.2 bleak==0.21.0 bluetooth-adapters==0.16.0 bluetooth-auto-recovery==1.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index 8ae3ed3db0a..05c56ef95e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -519,7 +519,7 @@ bimmer-connected==0.14.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.1.2 # homeassistant.components.bluetooth bleak==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eaa6a84ad89..2b132a606c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ bellows==0.36.1 bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.1.2 # homeassistant.components.bluetooth bleak==0.21.0 From b752419f25650f8a0f3aded6b2e883f154a95922 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 09:13:34 -0500 Subject: [PATCH 087/984] Bump aioesphomeapi to 16.0.4 (#99541) --- 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 bfb33c7b7d0..32d915f8b76 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==16.0.3", + "aioesphomeapi==16.0.4", "bluetooth-data-tools==1.11.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 05c56ef95e9..453eeac3f6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -232,7 +232,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.3 +aioesphomeapi==16.0.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b132a606c1..3a2c66c337e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -213,7 +213,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.3 +aioesphomeapi==16.0.4 # homeassistant.components.flo aioflo==2021.11.0 From 186e796e25c1f90eafe69399c4e93b75c5cee973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 09:30:39 -0500 Subject: [PATCH 088/984] Speed up fetching states by domain (#99467) --- homeassistant/core.py | 42 +++++++++++++++++++++++------------------- tests/test_core.py | 1 + 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 89269ae9158..bd596780759 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1261,7 +1261,7 @@ class State: "State max length is 255 characters." ) - self.entity_id = entity_id.lower() + self.entity_id = entity_id self.state = state self.attributes = ReadOnlyDict(attributes or {}) self.last_updated = last_updated or dt_util.utcnow() @@ -1412,11 +1412,12 @@ class State: class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_reservations", "_bus", "_loop") + __slots__ = ("_states", "_domain_index", "_reservations", "_bus", "_loop") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" self._states: dict[str, State] = {} + self._domain_index: dict[str, dict[str, State]] = {} self._reservations: set[str] = set() self._bus = bus self._loop = loop @@ -1440,13 +1441,13 @@ class StateMachine: return list(self._states) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return list(self._domain_index.get(domain_filter.lower(), ())) - return [ - state.entity_id - for state in self._states.values() - if state.domain in domain_filter - ] + states: list[str] = [] + for domain in domain_filter: + if domain_index := self._domain_index.get(domain): + states.extend(domain_index) + return states @callback def async_entity_ids_count( @@ -1460,11 +1461,9 @@ class StateMachine: return len(self._states) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return len(self._domain_index.get(domain_filter.lower(), ())) - return len( - [None for state in self._states.values() if state.domain in domain_filter] - ) + return sum(len(self._domain_index.get(domain, ())) for domain in domain_filter) def all(self, domain_filter: str | Iterable[str] | None = None) -> list[State]: """Create a list of all states.""" @@ -1484,11 +1483,13 @@ class StateMachine: return list(self._states.values()) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return list(self._domain_index.get(domain_filter.lower(), {}).values()) - return [ - state for state in self._states.values() if state.domain in domain_filter - ] + states: list[State] = [] + for domain in domain_filter: + if domain_index := self._domain_index.get(domain): + states.extend(domain_index.values()) + return states def get(self, entity_id: str) -> State | None: """Retrieve state of entity_id or None if not found. @@ -1524,13 +1525,12 @@ class StateMachine: """ entity_id = entity_id.lower() old_state = self._states.pop(entity_id, None) - - if entity_id in self._reservations: - self._reservations.remove(entity_id) + self._reservations.discard(entity_id) if old_state is None: return False + self._domain_index[old_state.domain].pop(entity_id) old_state.expire() self._bus.async_fire( EVENT_STATE_CHANGED, @@ -1652,6 +1652,10 @@ class StateMachine: if old_state is not None: old_state.expire() self._states[entity_id] = state + if not (domain_index := self._domain_index.get(state.domain)): + domain_index = {} + self._domain_index[state.domain] = domain_index + domain_index[entity_id] = state self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": state}, diff --git a/tests/test_core.py b/tests/test_core.py index 4f7916e757b..f4a80468050 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1938,6 +1938,7 @@ async def test_async_entity_ids_count(hass: HomeAssistant) -> None: assert hass.states.async_entity_ids_count() == 5 assert hass.states.async_entity_ids_count("light") == 3 + assert hass.states.async_entity_ids_count({"light", "vacuum"}) == 4 async def test_hassjob_forbid_coroutine() -> None: From c297eecb683884ce647750543ebf699e51fead61 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Sun, 3 Sep 2023 11:08:17 -0400 Subject: [PATCH 089/984] Fix recollect_waste month time boundary issue (#99429) --- .../components/recollect_waste/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index 21cf574d548..076067312eb 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -1,7 +1,7 @@ """The ReCollect Waste integration.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, timedelta from typing import Any from aiorecollect.client import Client, PickupEvent @@ -31,7 +31,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_get_pickup_events() -> list[PickupEvent]: """Get the next pickup.""" try: - return await client.async_get_pickup_events() + # Retrieve today through to 35 days in the future, to get + # coverage across a full two months boundary so that no + # upcoming pickups are missed. The api.recollect.net base API + # call returns only the current month when no dates are passed. + # This ensures that data about when the next pickup is will be + # returned when the next pickup is the first day of the next month. + # Ex: Today is August 31st, tomorrow is a pickup on September 1st. + today = date.today() + return await client.async_get_pickup_events( + start_date=today, + end_date=today + timedelta(days=35), + ) except RecollectError as err: raise UpdateFailed( f"Error while requesting data from ReCollect: {err}" From c94d4f501b30d1d015e1305d88c8a69f5fa8275e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 3 Sep 2023 17:13:49 +0200 Subject: [PATCH 090/984] Read modbus data before scan_interval (#99243) Read before scan_interval. --- homeassistant/components/modbus/base_platform.py | 4 +--- tests/components/modbus/conftest.py | 2 +- tests/components/modbus/test_sensor.py | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 7c3fcd78b05..e85857b5fb4 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -30,7 +30,6 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from homeassistant.helpers.restore_state import RestoreEntity from .const import ( - ACTIVE_SCAN_INTERVAL, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, @@ -115,8 +114,7 @@ class BasePlatform(Entity): def async_run(self) -> None: """Remote start entity.""" self.async_hold(update=False) - if self._scan_interval == 0 or self._scan_interval > ACTIVE_SCAN_INTERVAL: - self._cancel_call = async_call_later(self.hass, 1, self.async_update) + self._cancel_call = async_call_later(self.hass, 1, self.async_update) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 23d3ee522bb..d4c7dfa5e10 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -149,7 +149,7 @@ async def mock_do_cycle_fixture( mock_pymodbus_return, ) -> FrozenDateTimeFactory: """Trigger update call with time_changed event.""" - freezer.tick(timedelta(seconds=90)) + freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() return freezer diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index f72371ed42e..12d5d558408 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -267,7 +267,6 @@ async def test_config_wrong_struct_sensor( { CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 1, }, ], }, @@ -710,7 +709,6 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 1, }, ], }, @@ -935,7 +933,7 @@ async def test_lazy_error_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) + await do_next_cycle(hass, mock_do_cycle, 5) 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 @@ -1003,7 +1001,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No { CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 201, - CONF_SCAN_INTERVAL: 1, }, ], }, From c938b9e7a308de3198e8016a582c0f7e5061804a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Sep 2023 08:36:20 -0700 Subject: [PATCH 091/984] Rename nest test_sensor_sdm.py to test_sensor.py (#99512) --- tests/components/nest/{test_sensor_sdm.py => test_sensor.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/components/nest/{test_sensor_sdm.py => test_sensor.py} (100%) diff --git a/tests/components/nest/test_sensor_sdm.py b/tests/components/nest/test_sensor.py similarity index 100% rename from tests/components/nest/test_sensor_sdm.py rename to tests/components/nest/test_sensor.py From d19f617c2535a421a43895457cda90900e7cfa07 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 3 Sep 2023 17:48:25 +0200 Subject: [PATCH 092/984] Modbus switch, allow restore "unknown" (#99533) --- homeassistant/components/modbus/base_platform.py | 6 +++++- tests/components/modbus/test_switch.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index e85857b5fb4..b71f8c20215 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_SLAVE, CONF_STRUCTURE, CONF_UNIQUE_ID, + STATE_OFF, STATE_ON, ) from homeassistant.core import callback @@ -309,7 +310,10 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Handle entity which will be added.""" await self.async_base_added_to_hass() if state := await self.async_get_last_state(): - self._attr_is_on = state.state == STATE_ON + if state.state == STATE_ON: + self._attr_is_on = True + elif state.state == STATE_OFF: + self._attr_is_on = False async def async_turn(self, command: int) -> None: """Evaluate switch result.""" diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index dce4588d606..7a79e19869a 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -250,7 +250,7 @@ async def test_lazy_error_switch( @pytest.mark.parametrize( "mock_test_state", - [(State(ENTITY_ID, STATE_ON),)], + [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], indirect=True, ) @pytest.mark.parametrize( From ca442420952d0ac3d4f00019e7a173c6e0f7e189 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sun, 3 Sep 2023 20:22:59 +0300 Subject: [PATCH 093/984] Allow glances entries with same IP but different ports (#99536) --- homeassistant/components/glances/config_flow.py | 7 +++++-- tests/components/glances/test_config_flow.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 04e133248a6..58b81bc088e 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -61,11 +61,14 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors = {} if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) try: await validate_input(self.hass, user_input) return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input + title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + data=user_input, ) except CannotConnect: errors["base"] = "cannot_connect" diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index 187e319fe08..d4d25d8b86f 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -35,7 +35,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "0.0.0.0" + assert result["title"] == "0.0.0.0:61208" assert result["data"] == MOCK_USER_INPUT From 7931d74938060be9f5cc7b6d335d7b03394939f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 12:39:49 -0500 Subject: [PATCH 094/984] Make bond BPUP callback a HassJob (#99470) --- homeassistant/components/bond/entity.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 3b3ace98950..03a5f444579 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ATTR_SW_VERSION, ATTR_VIA_DEVICE, ) -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later @@ -68,6 +68,9 @@ class BondEntity(Entity): self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state self._apply_state() self._bpup_polling_fallback: CALLBACK_TYPE | None = None + self._async_update_if_bpup_not_alive_job = HassJob( + self._async_update_if_bpup_not_alive + ) @property def device_info(self) -> DeviceInfo: @@ -185,7 +188,7 @@ class BondEntity(Entity): self._bpup_polling_fallback = async_call_later( self.hass, _BPUP_ALIVE_SCAN_INTERVAL if alive else _FALLBACK_SCAN_INTERVAL, - self._async_update_if_bpup_not_alive, + self._async_update_if_bpup_not_alive_job, ) async def async_will_remove_from_hass(self) -> None: From f85a3861f2c30bbebe8bbaa34dda8b67843424f5 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 3 Sep 2023 21:04:58 +0200 Subject: [PATCH 095/984] Make validator for modbus table controlled (#99092) --- homeassistant/components/modbus/__init__.py | 1 - homeassistant/components/modbus/validators.py | 157 ++++++++++-------- tests/components/modbus/test_sensor.py | 16 +- 3 files changed, 89 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index cb36661d711..b4258d47d5e 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -171,7 +171,6 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( DataType.FLOAT32, DataType.FLOAT64, DataType.STRING, - DataType.STRING, DataType.CUSTOM, ] ), diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index f5f88ea5f59..aec781b065e 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -30,6 +30,8 @@ from .const import ( CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, + CONF_SWAP_WORD, + CONF_SWAP_WORD_BYTE, CONF_WRITE_TYPE, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, @@ -40,97 +42,108 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -ENTRY = namedtuple("ENTRY", ["struct_id", "register_count"]) +ENTRY = namedtuple( + "ENTRY", + [ + "struct_id", + "register_count", + "validate_parm", + ], +) +PARM_IS_LEGAL = namedtuple( + "PARM_IS_LEGAL", + [ + "count", + "structure", + "slave_count", + "swap_byte", + "swap_word", + ], +) +# PARM_IS_LEGAL defines if the keywords: +# count: .. +# structure: .. +# swap: byte +# swap: word +# swap: word_byte (identical to swap: word) +# are legal to use. +# These keywords are only legal with some datatype: ... +# As expressed in DEFAULT_STRUCT_FORMAT + DEFAULT_STRUCT_FORMAT = { - DataType.INT8: ENTRY("b", 1), - DataType.INT16: ENTRY("h", 1), - DataType.INT32: ENTRY("i", 2), - DataType.INT64: ENTRY("q", 4), - DataType.UINT8: ENTRY("c", 1), - DataType.UINT16: ENTRY("H", 1), - DataType.UINT32: ENTRY("I", 2), - DataType.UINT64: ENTRY("Q", 4), - DataType.FLOAT16: ENTRY("e", 1), - DataType.FLOAT32: ENTRY("f", 2), - DataType.FLOAT64: ENTRY("d", 4), - DataType.STRING: ENTRY("s", 1), + DataType.INT8: ENTRY("b", 1, PARM_IS_LEGAL(False, False, False, False, False)), + DataType.UINT8: ENTRY("c", 1, PARM_IS_LEGAL(False, False, False, False, False)), + DataType.INT16: ENTRY("h", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.UINT16: ENTRY("H", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.FLOAT16: ENTRY("e", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.INT32: ENTRY("i", 2, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.UINT32: ENTRY("I", 2, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.FLOAT32: ENTRY("f", 2, PARM_IS_LEGAL(False, False, True, True, True)), + 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.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)), } def struct_validator(config: dict[str, Any]) -> dict[str, Any]: """Sensor schema validator.""" - data_type = config[CONF_DATA_TYPE] - count = config.get(CONF_COUNT, 1) name = config[CONF_NAME] - structure = config.get(CONF_STRUCTURE) - slave_count = config.get(CONF_SLAVE_COUNT, 0) + 1 + data_type = config[CONF_DATA_TYPE] + if data_type == "int": + data_type = config[CONF_DATA_TYPE] = DataType.INT16 + count = config.get(CONF_COUNT, None) + structure = config.get(CONF_STRUCTURE, None) + slave_count = config.get(CONF_SLAVE_COUNT, None) swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) - if ( - slave_count > 1 - and count > 1 - and data_type not in (DataType.CUSTOM, DataType.STRING) - ): - error = f"{name} {CONF_COUNT} cannot be mixed with {data_type}" + validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm + if count and not validator.count: + error = f"{name}: `{CONF_COUNT}: {count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) - if config[CONF_DATA_TYPE] != DataType.CUSTOM: - if structure: - error = f"{name} structure: cannot be mixed with {data_type}" - + if not count and validator.count: + error = f"{name}: `{CONF_COUNT}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if structure and not validator.structure: + error = f"{name}: `{CONF_STRUCTURE}: {structure}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if not structure and validator.structure: + error = f"{name}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if slave_count and not validator.slave_count: + error = f"{name}: `{CONF_SLAVE_COUNT}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if swap_type != CONF_SWAP_NONE: + swap_type_validator = { + CONF_SWAP_NONE: False, + CONF_SWAP_BYTE: validator.swap_byte, + CONF_SWAP_WORD: validator.swap_word, + CONF_SWAP_WORD_BYTE: validator.swap_word, + }[swap_type] + if not swap_type_validator: + error = f"{name}: `{CONF_SWAP}:{swap_type}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) if config[CONF_DATA_TYPE] == DataType.CUSTOM: - if slave_count > 1: - error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SLAVE_COUNT}` / `{CONF_SLAVE}`" - raise vol.Invalid(error) - if swap_type != CONF_SWAP_NONE: - error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SWAP}`" - raise vol.Invalid(error) - if not structure: - error = ( - f"Error in sensor {name}. The `{CONF_STRUCTURE}` field cannot be empty" - ) - raise vol.Invalid(error) try: size = struct.calcsize(structure) except struct.error as err: - raise vol.Invalid(f"Error in {name} structure: {str(err)}") from err - - count = config.get(CONF_COUNT, 1) + raise vol.Invalid( + f"{name}: error in structure format --> {str(err)}" + ) from err bytecount = count * 2 if bytecount != size: raise vol.Invalid( - f"Structure request {size} bytes, " - f"but {count} registers have a size of {bytecount} bytes" + f"{name}: Size of structure is {size} bytes but `{CONF_COUNT}: {count}` is {bytecount} bytes" ) - return { - **config, - CONF_STRUCTURE: structure, - CONF_SWAP: swap_type, - } - if data_type not in DEFAULT_STRUCT_FORMAT: - error = f"Error in sensor {name}. data_type `{data_type}` not supported" - raise vol.Invalid(error) - if slave_count > 1 and data_type == DataType.STRING: - error = f"{name}: `{data_type}` illegal with `{CONF_SLAVE_COUNT}`" - raise vol.Invalid(error) - - if CONF_COUNT not in config: - config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count - if swap_type != CONF_SWAP_NONE: - if swap_type == CONF_SWAP_BYTE: - regs_needed = 1 - else: # CONF_SWAP_WORD_BYTE, CONF_SWAP_WORD - regs_needed = 2 - count = config[CONF_COUNT] - if count < regs_needed or (count % regs_needed) != 0: - raise vol.Invalid( - f"Error in sensor {name} swap({swap_type}) " - f"impossible because datatype({data_type}) is too small" - ) - structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" - if slave_count > 1: - structure = f">{slave_count}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" else: - structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" + config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count + if slave_count: + structure = ( + f">{slave_count + 1}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" + ) + else: + structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" return { **config, CONF_STRUCTURE: structure, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 12d5d558408..a746bcda3ba 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -187,7 +187,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - "Structure request 16 bytes, but 2 registers have a size of 4 bytes", + f"{TEST_ENTITY_NAME}: Size of structure is 16 bytes but `{CONF_COUNT}: 2` is 4 bytes", ), ( { @@ -212,12 +212,11 @@ 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: "", }, ] }, - f"Error in sensor {TEST_ENTITY_NAME}. The `structure` field cannot be empty", + f"{TEST_ENTITY_NAME}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ( { @@ -227,12 +226,11 @@ 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: "1s", }, ] }, - "Structure request 1 bytes, but 4 registers have a size of 8 bytes", + f"{TEST_ENTITY_NAME}: Size of structure is 1 bytes but `{CONF_COUNT}: 4` is 8 bytes", ), ( { @@ -247,7 +245,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `structure` illegal with `swap`", + f"{TEST_ENTITY_NAME}: `{CONF_SWAP}:{CONF_SWAP_WORD}` cannot be combined with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ], ) @@ -1011,7 +1009,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, @@ -1020,7 +1017,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT16, }, @@ -1029,7 +1025,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1038,7 +1033,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1047,7 +1041,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1056,7 +1049,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD_BYTE, CONF_DATA_TYPE: DataType.UINT32, }, From f52ba7042d3c6b3ce21b95b512f6bbb34a26ce6d Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 3 Sep 2023 21:31:25 +0200 Subject: [PATCH 096/984] Bump aiounifi to v60 (#99548) --- 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 363313bf878..cb1c8f1c0dc 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==58"], + "requirements": ["aiounifi==60"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 453eeac3f6e..63602441668 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -364,7 +364,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==58 +aiounifi==60 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a2c66c337e..f0de1bd23ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -339,7 +339,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==58 +aiounifi==60 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 8afab4845af95cdb144cc80b6e6e04b3df417087 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Sep 2023 13:52:03 -0700 Subject: [PATCH 097/984] Remove nest legacy service descriptions and translations (#99510) --- homeassistant/components/nest/services.yaml | 46 ------------------ homeassistant/components/nest/strings.json | 52 --------------------- 2 files changed, 98 deletions(-) delete mode 100644 homeassistant/components/nest/services.yaml diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml deleted file mode 100644 index 5f68bd6a1f2..00000000000 --- a/homeassistant/components/nest/services.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# Describes the format for available Nest services - -set_away_mode: - fields: - away_mode: - required: true - selector: - select: - options: - - "away" - - "home" - structure: - example: "Apartment" - selector: - object: - -set_eta: - fields: - eta: - required: true - selector: - time: - eta_window: - default: "00:01" - selector: - time: - trip_id: - example: "Leave Work" - selector: - text: - structure: - example: "Apartment" - selector: - object: - -cancel_eta: - fields: - trip_id: - required: true - example: "Leave Work" - selector: - text: - structure: - example: "Apartment" - selector: - object: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 2c2def6b7a3..717ce5075f7 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -68,57 +68,5 @@ "title": "Legacy Works With Nest has been removed", "description": "Legacy Works With Nest has been removed from Home Assistant, and the API shuts down as of September 2023.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." } - }, - "services": { - "set_away_mode": { - "name": "Set away mode", - "description": "Sets the away mode for a Nest structure.", - "fields": { - "away_mode": { - "name": "Away mode", - "description": "New mode to set." - }, - "structure": { - "name": "Structure", - "description": "Name(s) of structure(s) to change. Defaults to all structures if not specified." - } - } - }, - "set_eta": { - "name": "Set estimated time of arrival", - "description": "Sets or update the estimated time of arrival window for a Nest structure.", - "fields": { - "eta": { - "name": "ETA", - "description": "Estimated time of arrival from now." - }, - "eta_window": { - "name": "ETA window", - "description": "Estimated time of arrival window." - }, - "trip_id": { - "name": "Trip ID", - "description": "Unique ID for the trip. Default is auto-generated using a timestamp." - }, - "structure": { - "name": "[%key:component::nest::services::set_away_mode::fields::structure::name%]", - "description": "[%key:component::nest::services::set_away_mode::fields::structure::description%]" - } - } - }, - "cancel_eta": { - "name": "Cancel ETA", - "description": "Cancels an existing estimated time of arrival window for a Nest structure.", - "fields": { - "trip_id": { - "name": "[%key:component::nest::services::set_eta::fields::trip_id::name%]", - "description": "Unique ID for the trip." - }, - "structure": { - "name": "[%key:component::nest::services::set_away_mode::fields::structure::name%]", - "description": "[%key:component::nest::services::set_away_mode::fields::structure::description%]" - } - } - } } } From 377d9f6687372a4877b23d0cc434b71cf1dd3005 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 17:06:21 -0500 Subject: [PATCH 098/984] Bump zeroconf to 0.96.0 (#99549) --- 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 718f3047a07..53b0dd5f5b5 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.93.1"] + "requirements": ["zeroconf==0.96.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c1b5f5a947f..d1f86cbbd84 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.93.1 +zeroconf==0.96.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 63602441668..2a01cfc6f7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.93.1 +zeroconf==0.96.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0de1bd23ec..b0246a2226f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.93.1 +zeroconf==0.96.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 115518cab9b4542907032cb37fc733d44a7e97af Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Sep 2023 02:58:01 +0200 Subject: [PATCH 099/984] Fix tolo test warning (#99555) --- tests/components/tolo/test_config_flow.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index aa88766c395..df36570497b 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -23,6 +23,18 @@ def toloclient_fixture() -> Mock: yield toloclient +@pytest.fixture +def coordinator_toloclient() -> Mock: + """Patch ToloClient in async_setup_entry. + + Throw exception to abort entry setup and prevent socket IO. Only testing config flow. + """ + with patch( + "homeassistant.components.tolo.ToloClient", side_effect=Exception + ) as toloclient: + yield toloclient + + async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) -> None: """Test a user initiated config flow with provided host which times out.""" toloclient().get_status_info.side_effect = ResponseTimedOutError() @@ -38,7 +50,9 @@ async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) - assert result["errors"] == {"base": "cannot_connect"} -async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock) -> None: +async def test_user_walkthrough( + hass: HomeAssistant, toloclient: Mock, coordinator_toloclient: Mock +) -> None: """Test complete user flow with first wrong and then correct host.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -70,7 +84,9 @@ async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock) -> None: assert result3["data"][CONF_HOST] == "127.0.0.1" -async def test_dhcp(hass: HomeAssistant, toloclient: Mock) -> None: +async def test_dhcp( + hass: HomeAssistant, toloclient: Mock, coordinator_toloclient: Mock +) -> None: """Test starting a flow from discovery.""" toloclient().get_status_info.side_effect = lambda *args, **kwargs: object() From 735b5cf1a015b493ede0f18586c3bf8e5584cebf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Sep 2023 02:58:13 +0200 Subject: [PATCH 100/984] Fix sql test warning (#99556) --- tests/components/sql/test_sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 3d0e2768ade..cb988d3f2d4 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -502,6 +502,9 @@ async def test_multiple_sensors_using_same_db( assert state.state == "5" assert state.attributes["value"] == 5 + with patch("sqlalchemy.engine.base.Engine.dispose"): + await hass.async_stop() + async def test_engine_is_disposed_at_stop( recorder_mock: Recorder, hass: HomeAssistant From 9d6cab8fe604072f3e6aff1de8b29364e388eefa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Sep 2023 07:33:46 +0200 Subject: [PATCH 101/984] Fix loading filesize coordinator from wrong place (#99547) * Fix loading filesize coordinator from wrong place * aboslute in executor * combine into executor --- homeassistant/components/filesize/__init__.py | 14 ++++-- .../components/filesize/coordinator.py | 48 +++++++++++++++++++ homeassistant/components/filesize/sensor.py | 45 ++--------------- 3 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/filesize/coordinator.py diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 73f060e79b7..9d7cc99421f 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -9,10 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS +from .coordinator import FileSizeCoordinator -def _check_path(hass: HomeAssistant, path: str) -> None: - """Check if path is valid and allowed.""" +def _get_full_path(hass: HomeAssistant, path: str) -> str: + """Check if path is valid, allowed and return full path.""" get_path = pathlib.Path(path) if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") @@ -20,10 +21,17 @@ def _check_path(hass: HomeAssistant, path: str) -> None: if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + return str(get_path.absolute()) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) + full_path = await hass.async_add_executor_job( + _get_full_path, hass, entry.data[CONF_FILE_PATH] + ) + coordinator = FileSizeCoordinator(hass, full_path) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py new file mode 100644 index 00000000000..75411f84975 --- /dev/null +++ b/homeassistant/components/filesize/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for monitoring the size of a file.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +import os + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime]]): + """Filesize coordinator.""" + + def __init__(self, hass: HomeAssistant, path: str) -> None: + """Initialize filesize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=60), + always_update=False, + ) + self._path = path + + async def _async_update_data(self) -> dict[str, float | int | datetime]: + """Fetch file information.""" + try: + statinfo = await self.hass.async_add_executor_job(os.stat, self._path) + except OSError as error: + raise UpdateFailed(f"Can not retrieve file statistics {error}") from error + + size = statinfo.st_size + last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) + + _LOGGER.debug("size %s, last updated %s", size, last_updated) + data: dict[str, int | float | datetime] = { + "file": round(size / 1e6, 2), + "bytes": size, + "last_updated": last_updated, + } + + return data diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 0e600363640..c8e5dae5892 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -1,9 +1,8 @@ """Sensor for monitoring the size of a file.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging -import os import pathlib from homeassistant.components.sensor import ( @@ -17,14 +16,10 @@ from homeassistant.const import CONF_FILE_PATH, EntityCategory, UnitOfInformatio 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, - DataUpdateCoordinator, - UpdateFailed, -) -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import FileSizeCoordinator _LOGGER = logging.getLogger(__name__) @@ -80,40 +75,6 @@ async def async_setup_entry( ) -class FileSizeCoordinator(DataUpdateCoordinator): - """Filesize coordinator.""" - - def __init__(self, hass: HomeAssistant, path: str) -> None: - """Initialize filesize coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=60), - always_update=False, - ) - self._path = path - - async def _async_update_data(self) -> dict[str, float | int | datetime]: - """Fetch file information.""" - try: - statinfo = await self.hass.async_add_executor_job(os.stat, self._path) - except OSError as error: - raise UpdateFailed(f"Can not retrieve file statistics {error}") from error - - size = statinfo.st_size - last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) - - _LOGGER.debug("size %s, last updated %s", size, last_updated) - data: dict[str, int | float | datetime] = { - "file": round(size / 1e6, 2), - "bytes": size, - "last_updated": last_updated, - } - - return data - - class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity): """Filesize sensor.""" From 1dc724274e5dee341d804ca13b20b62596f07e3b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:05:59 +0200 Subject: [PATCH 102/984] Use shorthand attributes for Heos (#99344) --- homeassistant/components/heos/media_player.py | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index e2487e90a99..8502dec28fa 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -114,6 +114,8 @@ class HeosMediaPlayer(MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC _attr_should_poll = False + _attr_supported_features = BASE_SUPPORTED_FEATURES + _attr_media_image_remotely_accessible = True _attr_has_entity_name = True _attr_name = None @@ -122,9 +124,16 @@ class HeosMediaPlayer(MediaPlayerEntity): self._media_position_updated_at = None self._player = player self._signals = [] - self._attr_supported_features = BASE_SUPPORTED_FEATURES self._source_manager = None self._group_manager = None + self._attr_unique_id = str(player.player_id) + self._attr_device_info = DeviceInfo( + identifiers={(HEOS_DOMAIN, player.player_id)}, + manufacturer="HEOS", + model=player.model, + name=player.name, + sw_version=player.version, + ) async def _player_update(self, player_id, event): """Handle player attribute updated.""" @@ -306,17 +315,6 @@ class HeosMediaPlayer(MediaPlayerEntity): """Return True if the device is available.""" return self._player.available - @property - def device_info(self) -> DeviceInfo: - """Get attributes about the device.""" - return DeviceInfo( - identifiers={(HEOS_DOMAIN, self._player.player_id)}, - manufacturer="HEOS", - model=self._player.model, - name=self._player.name, - sw_version=self._player.version, - ) - @property def extra_state_attributes(self) -> dict[str, Any]: """Get additional attribute about the state.""" @@ -377,11 +375,6 @@ class HeosMediaPlayer(MediaPlayerEntity): return None return self._media_position_updated_at - @property - def media_image_remotely_accessible(self) -> bool: - """If the image url is remotely accessible.""" - return True - @property def media_image_url(self) -> str: """Image url of current playing media.""" @@ -414,11 +407,6 @@ class HeosMediaPlayer(MediaPlayerEntity): """State of the player.""" return PLAY_STATE_TO_STATE[self._player.state] - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return str(self._player.player_id) - @property def volume_level(self) -> float: """Volume level of the media player (0..1).""" From 8d3828ae5400933106b51f8016e973091d06bc48 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 4 Sep 2023 10:07:15 +0300 Subject: [PATCH 103/984] Add strict typing to glances (#99537) --- .strict-typing | 1 + homeassistant/components/glances/coordinator.py | 3 ++- homeassistant/components/glances/sensor.py | 7 +++++-- mypy.ini | 10 ++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index 2a6e9b04cbe..4a4151ce606 100644 --- a/.strict-typing +++ b/.strict-typing @@ -136,6 +136,7 @@ homeassistant.components.fully_kiosk.* homeassistant.components.geo_location.* homeassistant.components.geocaching.* homeassistant.components.gios.* +homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_sheets.* diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 24a2e23a013..8d2bd0daaa3 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -35,6 +35,7 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - return await self.api.get_ha_sensor_data() + data = await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err + return data or {} diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index cd9c3a9135d..78aa5ffbf0a 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -346,5 +347,7 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit value = self.coordinator.data[self.entity_description.type] if isinstance(value.get(self._sensor_name_prefix), dict): - return value[self._sensor_name_prefix][self.entity_description.key] - return value[self.entity_description.key] + return cast( + StateType, value[self._sensor_name_prefix][self.entity_description.key] + ) + return cast(StateType, value[self.entity_description.key]) diff --git a/mypy.ini b/mypy.ini index 178b82fd359..14eb6bba841 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1122,6 +1122,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.glances.*] +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.goalzero.*] check_untyped_defs = true disallow_incomplete_defs = true From f545389549df23ae5c5ed49e77f9c253516d53d2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:08:32 +0200 Subject: [PATCH 104/984] Move static shorthand devolo attributes outside of constructor (#99234) Co-authored-by: Guido Schmitz --- .../components/devolo_home_control/climate.py | 13 +++++----- .../components/devolo_home_control/cover.py | 25 +++++-------------- .../components/devolo_home_control/light.py | 3 ++- .../components/devolo_home_control/sensor.py | 17 ++++++++----- .../components/devolo_home_control/switch.py | 2 +- 5 files changed, 27 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 227b4796883..e27d5a315a5 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -50,6 +50,13 @@ async def async_setup_entry( class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntity): """Representation of a climate/thermostat device within devolo Home Control.""" + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_precision = PRECISION_TENTHS + _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_modes = [HVACMode.HEAT] + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -60,14 +67,8 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit element_uid=element_uid, ) - self._attr_hvac_mode = HVACMode.HEAT - self._attr_hvac_modes = [HVACMode.HEAT] self._attr_min_temp = self._multi_level_switch_property.min self._attr_max_temp = self._multi_level_switch_property.max - self._attr_precision = PRECISION_TENTHS - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - self._attr_target_temperature_step = PRECISION_HALVES - self._attr_temperature_unit = UnitOfTemperature.CELSIUS @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index a23c3fde585..b76948bcee7 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -3,9 +3,6 @@ from __future__ import annotations from typing import Any -from devolo_home_control_api.devices.zwave import Zwave -from devolo_home_control_api.homecontrol import HomeControl - from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, @@ -43,22 +40,12 @@ async def async_setup_entry( class DevoloCoverDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, CoverEntity): """Representation of a cover device within devolo Home Control.""" - def __init__( - self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str - ) -> None: - """Initialize a climate entity within devolo Home Control.""" - super().__init__( - homecontrol=homecontrol, - device_instance=device_instance, - element_uid=element_uid, - ) - - self._attr_device_class = CoverDeviceClass.BLIND - self._attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + _attr_device_class = CoverDeviceClass.BLIND @property def current_cover_position(self) -> int: diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 93a66e345ec..e91466c7ece 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -39,6 +39,8 @@ async def async_setup_entry( class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): """Representation of a light within devolo Home Control.""" + _attr_color_mode = ColorMode.BRIGHTNESS + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -49,7 +51,6 @@ class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): element_uid=element_uid, ) - self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._binary_switch_property = device_instance.binary_switch_property.get( element_uid.replace("Dimmer", "BinarySwitch") diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index b7e2a30b4c1..fa11424ae94 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -123,6 +123,12 @@ class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): """Representation of a battery entity within devolo Home Control.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = PERCENTAGE + _attr_name = "Battery level" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -134,11 +140,6 @@ class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): element_uid=element_uid, ) - self._attr_device_class = DEVICE_CLASS_MAPPING.get("battery") - self._attr_state_class = STATE_CLASS_MAPPING.get("battery") - self._attr_entity_category = EntityCategory.DIAGNOSTIC - self._attr_native_unit_of_measurement = PERCENTAGE - self._attr_name = "Battery level" self._value = device_instance.battery_level @@ -175,7 +176,11 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): @property def unique_id(self) -> str: - """Return the unique ID of the entity.""" + """Return the unique ID of the entity. + + As both sensor types share the same element_uid we need to extend original + self._attr_unique_id to be really unique. + """ return f"{self._attr_unique_id}_{self._sensor_type}" def _sync(self, message: tuple) -> None: diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index 9b96e58da60..c442cc55763 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -46,7 +46,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: - """Initialize an devolo Switch.""" + """Initialize a devolo Switch.""" super().__init__( homecontrol=homecontrol, device_instance=device_instance, From f4f98010f9dd2018d87302ba030feac91632ef99 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:15:01 +0200 Subject: [PATCH 105/984] Remove unused attributes from Econet (#99242) --- homeassistant/components/econet/__init__.py | 7 +----- homeassistant/components/econet/climate.py | 22 +++++-------------- .../components/econet/water_heater.py | 12 +++++----- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 3005993bf99..36cdeb68821 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -13,7 +13,7 @@ from pyeconet.errors import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform, UnitOfTemperature +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo @@ -137,8 +137,3 @@ class EcoNetEntity(Entity): manufacturer="Rheem", name=self._econet.device_name, ) - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return UnitOfTemperature.FAHRENHEIT diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index 7233d135f2e..e77c4face74 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -16,7 +16,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -62,23 +62,21 @@ async def async_setup_entry( class EcoNetThermostat(EcoNetEntity, ClimateEntity): - """Define a Econet thermostat.""" + """Define an Econet thermostat.""" + + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT def __init__(self, thermostat): """Initialize.""" super().__init__(thermostat) - self._running = thermostat.running - self._poll = True - self.econet_state_to_ha = {} - self.ha_state_to_econet = {} - self.op_list = [] + self._attr_hvac_modes = [] for mode in self._econet.modes: if mode not in [ ThermostatOperationMode.UNKNOWN, ThermostatOperationMode.EMERGENCY_HEAT, ]: ha_mode = ECONET_STATE_TO_HA[mode] - self.op_list.append(ha_mode) + self._attr_hvac_modes.append(ha_mode) @property def supported_features(self) -> ClimateEntityFeature: @@ -142,14 +140,6 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): """Return true if aux heater.""" return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT - @property - def hvac_modes(self): - """Return hvac operation ie. heat, cool mode. - - Needs to be one of HVAC_MODE_*. - """ - return self.op_list - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool, mode. diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index c94afd8b5d7..cbaf4551d03 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -16,7 +16,7 @@ from homeassistant.components.water_heater import ( WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -56,20 +56,20 @@ async def async_setup_entry( class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): - """Define a Econet water heater.""" + """Define an Econet water heater.""" + + _attr_should_poll = True # Override False default from EcoNetEntity + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT def __init__(self, water_heater): """Initialize.""" super().__init__(water_heater) self._running = water_heater.running - self._attr_should_poll = True # Override False default from EcoNetEntity self.water_heater = water_heater - self.econet_state_to_ha = {} - self.ha_state_to_econet = {} @callback def on_update_received(self): - """Update was pushed from the ecoent API.""" + """Update was pushed from the econet API.""" if self._running != self.water_heater.running: # Water heater running state has changed so check usage on next update self._attr_should_poll = True From 13ebb68b84f4856a9f62a7d6d72a893b74afca4a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:15:25 +0200 Subject: [PATCH 106/984] Use shorthand attributes in Home connect (#99385) Co-authored-by: Robert Resch --- .../components/home_connect/entity.py | 30 +++-------- .../components/home_connect/light.py | 52 +++++++----------- .../components/home_connect/sensor.py | 53 +++++++------------ .../components/home_connect/switch.py | 35 ++++-------- 4 files changed, 55 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 12fe7be3be9..d60f8a96e09 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -21,8 +21,14 @@ class HomeConnectEntity(Entity): def __init__(self, device: HomeConnectDevice, desc: str) -> None: """Initialize the entity.""" self.device = device - self.desc = desc - self._name = f"{self.device.appliance.name} {desc}" + self._attr_name = f"{device.appliance.name} {desc}" + self._attr_unique_id = f"{device.appliance.haId}-{desc}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.appliance.haId)}, + manufacturer=device.appliance.brand, + model=device.appliance.vib, + name=device.appliance.name, + ) async def async_added_to_hass(self): """Register callbacks.""" @@ -38,26 +44,6 @@ class HomeConnectEntity(Entity): if ha_id == self.device.appliance.haId: self.async_entity_update() - @property - def name(self): - """Return the name of the node (used for Entity_ID).""" - return self._name - - @property - def unique_id(self): - """Return the unique id base on the id returned by Home Connect and the entity name.""" - return f"{self.device.appliance.haId}-{self.desc}" - - @property - def device_info(self) -> DeviceInfo: - """Return info about the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device.appliance.haId)}, - manufacturer=self.device.appliance.brand, - model=self.device.appliance.vib, - name=self.device.appliance.name, - ) - @callback def async_entity_update(self): """Update the entity.""" diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 17dc842358f..7e65fed034d 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -59,11 +59,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): def __init__(self, device, desc, ambient): """Initialize the entity.""" super().__init__(device, desc) - self._state = None - self._brightness = None - self._hs_color = None self._ambient = ambient - if self._ambient: + if ambient: self._brightness_key = BSH_AMBIENT_LIGHT_BRIGHTNESS self._key = BSH_AMBIENT_LIGHT_ENABLED self._custom_color_key = BSH_AMBIENT_LIGHT_CUSTOM_COLOR @@ -78,21 +75,6 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - @property - def is_on(self): - """Return true if the light is on.""" - return bool(self._state) - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): - """Return the color property.""" - return self._hs_color - async def async_turn_on(self, **kwargs: Any) -> None: """Switch the light on, change brightness, change color.""" if self._ambient: @@ -113,12 +95,12 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): ) except HomeConnectError as err: _LOGGER.error("Error while trying selecting customcolor: %s", err) - if self._brightness is not None: - brightness = 10 + ceil(self._brightness / 255 * 90) + if self._attr_brightness is not None: + brightness = 10 + ceil(self._attr_brightness / 255 * 90) if ATTR_BRIGHTNESS in kwargs: brightness = 10 + ceil(kwargs[ATTR_BRIGHTNESS] / 255 * 90) - hs_color = kwargs.get(ATTR_HS_COLOR, self._hs_color) + hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) if hs_color is not None: rgb = color_util.color_hsv_to_RGB( @@ -170,32 +152,34 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): async def async_update(self) -> None: """Update the light's status.""" if self.device.appliance.status.get(self._key, {}).get(ATTR_VALUE) is True: - self._state = True + self._attr_is_on = True elif self.device.appliance.status.get(self._key, {}).get(ATTR_VALUE) is False: - self._state = False + self._attr_is_on = False else: - self._state = None + self._attr_is_on = None - _LOGGER.debug("Updated, new light state: %s", self._state) + _LOGGER.debug("Updated, new light state: %s", self._attr_is_on) if self._ambient: color = self.device.appliance.status.get(self._custom_color_key, {}) if not color: - self._hs_color = None - self._brightness = None + self._attr_hs_color = None + self._attr_brightness = None else: colorvalue = color.get(ATTR_VALUE)[1:] rgb = color_util.rgb_hex_to_rgb_list(colorvalue) hsv = color_util.color_RGB_to_hsv(rgb[0], rgb[1], rgb[2]) - self._hs_color = [hsv[0], hsv[1]] - self._brightness = ceil((hsv[2] - 10) * 255 / 90) - _LOGGER.debug("Updated, new brightness: %s", self._brightness) + self._attr_hs_color = (hsv[0], hsv[1]) + self._attr_brightness = ceil((hsv[2] - 10) * 255 / 90) + _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) else: brightness = self.device.appliance.status.get(self._brightness_key, {}) if brightness is None: - self._brightness = None + self._attr_brightness = None else: - self._brightness = ceil((brightness.get(ATTR_VALUE) - 10) * 255 / 90) - _LOGGER.debug("Updated, new brightness: %s", self._brightness) + self._attr_brightness = ceil( + (brightness.get(ATTR_VALUE) - 10) * 255 / 90 + ) + _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index efd2a9b34dd..07edfb4bd4b 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,6 +1,7 @@ """Provides a sensor for Home Connect.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging +from typing import cast from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -40,62 +41,44 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): def __init__(self, device, desc, key, unit, icon, device_class, sign=1): """Initialize the entity.""" super().__init__(device, desc) - self._state = None self._key = key - self._unit = unit - self._icon = icon - self._device_class = device_class self._sign = sign - - @property - def native_value(self): - """Return sensor value.""" - return self._state + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + self._attr_device_class = device_class @property def available(self) -> bool: """Return true if the sensor is available.""" - return self._state is not None + return self._attr_native_value is not None async def async_update(self) -> None: """Update the sensor's status.""" status = self.device.appliance.status if self._key not in status: - self._state = None + self._attr_native_value = None elif self.device_class == SensorDeviceClass.TIMESTAMP: if ATTR_VALUE not in status[self._key]: - self._state = None + self._attr_native_value = None elif ( - self._state is not None + self._attr_native_value is not None and self._sign == 1 - and self._state < dt_util.utcnow() + and isinstance(self._attr_native_value, datetime) + and self._attr_native_value < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. - self._state = None + self._attr_native_value = None else: seconds = self._sign * float(status[self._key][ATTR_VALUE]) - self._state = dt_util.utcnow() + timedelta(seconds=seconds) + self._attr_native_value = dt_util.utcnow() + timedelta(seconds=seconds) else: - self._state = status[self._key].get(ATTR_VALUE) + self._attr_native_value = status[self._key].get(ATTR_VALUE) if self._key == BSH_OPERATION_STATE: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state - self._state = self._state.split(".")[-1] - _LOGGER.debug("Updated, new state: %s", self._state) - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def device_class(self): - """Return the device class.""" - return self._device_class + self._attr_native_value = cast(str, self._attr_native_value).split(".")[ + -1 + ] + _LOGGER.debug("Updated, new state: %s", self._attr_native_value) diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 61dd11dbc6f..dbcbfde9dc2 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -56,13 +56,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): ) super().__init__(device, desc) self.program_name = program_name - self._state = None - self._remote_allowed = None - - @property - def is_on(self): - """Return true if the switch is on.""" - return bool(self._state) async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" @@ -88,10 +81,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Update the switch's status.""" state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) if state.get(ATTR_VALUE) == self.program_name: - self._state = True + self._attr_is_on = True else: - self._state = False - _LOGGER.debug("Updated, new state: %s", self._state) + self._attr_is_on = False + _LOGGER.debug("Updated, new state: %s", self._attr_is_on) class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): @@ -100,12 +93,6 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): def __init__(self, device): """Inititialize the entity.""" super().__init__(device, "Power") - self._state = None - - @property - def is_on(self): - """Return true if the switch is on.""" - return bool(self._state) async def async_turn_on(self, **kwargs: Any) -> None: """Switch the device on.""" @@ -116,7 +103,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn on device: %s", err) - self._state = False + self._attr_is_on = False self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: @@ -130,7 +117,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn off device: %s", err) - self._state = True + self._attr_is_on = True self.async_entity_update() async def async_update(self) -> None: @@ -139,12 +126,12 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) == BSH_POWER_ON ): - self._state = True + self._attr_is_on = True elif ( self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) == self.device.power_off_state ): - self._state = False + self._attr_is_on = False elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get( ATTR_VALUE, None ) in [ @@ -156,12 +143,12 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): "BSH.Common.EnumType.OperationState.Aborting", "BSH.Common.EnumType.OperationState.Finished", ]: - self._state = True + self._attr_is_on = True elif ( self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(ATTR_VALUE) == "BSH.Common.EnumType.OperationState.Inactive" ): - self._state = False + self._attr_is_on = False else: - self._state = None - _LOGGER.debug("Updated, new state: %s", self._state) + self._attr_is_on = None + _LOGGER.debug("Updated, new state: %s", self._attr_is_on) From 69117cb8e3cff03721473b96f66b9048e1184945 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:23:33 +0200 Subject: [PATCH 107/984] Use shorthand attributes for DLNA dmr (#99236) --- homeassistant/components/dlna_dmr/media_player.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 50877756d52..3a57ba2c8ce 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -129,6 +129,9 @@ class DlnaDmrEntity(MediaPlayerEntity): # determine whether further device polling is required. _attr_should_poll = True + # Name of the current sound mode, not supported by DLNA + _attr_sound_mode = None + def __init__( self, udn: str, @@ -745,11 +748,6 @@ class DlnaDmrEntity(MediaPlayerEntity): "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat ) - @property - def sound_mode(self) -> str | None: - """Name of the current sound mode, not supported by DLNA.""" - return None - @property def sound_mode_list(self) -> list[str] | None: """List of available sound modes.""" From 9144ef7ed8c04ebfe5d8c240685400beccc42922 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Mon, 4 Sep 2023 00:30:56 -0700 Subject: [PATCH 108/984] Enumerate available states in Prometheus startup (#97993) --- .../components/prometheus/__init__.py | 24 +++++++++++----- tests/components/prometheus/test_init.py | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index adc5225b286..1818f308239 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -120,10 +120,15 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: default_metric, ) - hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed) + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) hass.bus.listen( EVENT_ENTITY_REGISTRY_UPDATED, metrics.handle_entity_registry_updated ) + + for state in hass.states.all(): + if entity_filter(state.entity_id): + metrics.handle_state(state) + return True @@ -162,16 +167,13 @@ class PrometheusMetrics: self._metrics = {} self._climate_units = climate_units - def handle_state_changed(self, event): - """Listen for new messages on the bus, and add them to Prometheus.""" + def handle_state_changed_event(self, event): + """Handle new messages from the bus.""" if (state := event.data.get("new_state")) is None: return - entity_id = state.entity_id - _LOGGER.debug("Handling state update for %s", entity_id) - domain, _ = hacore.split_entity_id(entity_id) - if not self._filter(state.entity_id): + _LOGGER.debug("Filtered out entity %s", state.entity_id) return if (old_state := event.data.get("old_state")) is not None and ( @@ -179,6 +181,14 @@ class PrometheusMetrics: ) != state.attributes.get(ATTR_FRIENDLY_NAME): self._remove_labelsets(old_state.entity_id, old_friendly_name) + self.handle_state(state) + + def handle_state(self, state): + """Add/update a state in Prometheus.""" + entity_id = state.entity_id + _LOGGER.debug("Handling state update for %s", entity_id) + domain, _ = hacore.split_entity_id(entity_id) + ignored_states = (STATE_UNAVAILABLE, STATE_UNKNOWN) handler = f"_handle_{domain}" diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 82a205eb259..07a666946fb 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -107,6 +107,34 @@ async def generate_latest_metrics(client): return body +@pytest.mark.parametrize("namespace", [""]) +async def test_setup_enumeration(hass, hass_client, entity_registry, namespace): + """Test that setup enumerates existing states/entities.""" + + # The order of when things are created must be carefully controlled in + # this test, so we don't use fixtures. + + sensor_1 = entity_registry.async_get_or_create( + domain=sensor.DOMAIN, + platform="test", + unique_id="sensor_1", + unit_of_measurement=UnitOfTemperature.CELSIUS, + original_device_class=SensorDeviceClass.TEMPERATURE, + suggested_object_id="outside_temperature", + original_name="Outside Temperature", + ) + set_state_with_entry(hass, sensor_1, 12.3, {}) + assert await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}) + + client = await hass_client() + body = await generate_latest_metrics(client) + assert ( + 'homeassistant_sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 12.3' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_view_empty_namespace(client, sensor_entities) -> None: """Test prometheus metrics view.""" From de1de926a9bd86d7173e854e4db903233ebfb1f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 02:52:21 -0500 Subject: [PATCH 109/984] Bump zeroconf to 0.97.0 (#99554) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.96.0...0.97.0 --- 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 53b0dd5f5b5..4969b2a5a65 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.96.0"] + "requirements": ["zeroconf==0.97.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d1f86cbbd84..a1d4a0c7bf9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.96.0 +zeroconf==0.97.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 2a01cfc6f7d..c70cbe30d5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.96.0 +zeroconf==0.97.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0246a2226f..487b2ecf4b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.96.0 +zeroconf==0.97.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From b9536732bc0ddda42bba58641a978b3fff9ead0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiit=20R=C3=A4tsep?= Date: Mon, 4 Sep 2023 11:03:58 +0300 Subject: [PATCH 110/984] Fix battery reading in SOMA API (#99403) Co-authored-by: Robert Resch --- homeassistant/components/soma/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 6472f6934e0..d1c0de188a0 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -43,11 +43,12 @@ class SomaSensor(SomaEntity, SensorEntity): async def async_update(self) -> None: """Update the sensor with the latest data.""" response = await self.get_battery_level_from_api() - - # https://support.somasmarthome.com/hc/en-us/articles/360026064234-HTTP-API - # battery_level response is expected to be min = 360, max 410 for - # 0-100% levels above 410 are consider 100% and below 360, 0% as the - # device considers 360 the minimum to move the motor. - _battery = round(2 * (response["battery_level"] - 360)) + _battery = response.get("battery_percentage") + if _battery is None: + # https://support.somasmarthome.com/hc/en-us/articles/360026064234-HTTP-API + # battery_level response is expected to be min = 360, max 410 for + # 0-100% levels above 410 are consider 100% and below 360, 0% as the + # device considers 360 the minimum to move the motor. + _battery = round(2 * (response["battery_level"] - 360)) battery = max(min(100, _battery), 0) self.battery_state = battery From fa0b61e96a907d955edf49d9288860f9cddfa984 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 4 Sep 2023 11:07:08 +0200 Subject: [PATCH 111/984] Move london underground coordinator to its own file (#99550) --- CODEOWNERS | 1 + .../components/london_underground/const.py | 26 +++++++++ .../london_underground/coordinator.py | 30 +++++++++++ .../london_underground/manifest.json | 2 +- .../components/london_underground/sensor.py | 53 ++----------------- 5 files changed, 62 insertions(+), 50 deletions(-) create mode 100644 homeassistant/components/london_underground/const.py create mode 100644 homeassistant/components/london_underground/coordinator.py diff --git a/CODEOWNERS b/CODEOWNERS index b937c2769fc..42537d4e3f1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -707,6 +707,7 @@ build.json @home-assistant/supervisor /tests/components/logger/ @home-assistant/core /homeassistant/components/logi_circle/ @evanjd /tests/components/logi_circle/ @evanjd +/homeassistant/components/london_underground/ @jpbede /homeassistant/components/lookin/ @ANMalko @bdraco /tests/components/lookin/ @ANMalko @bdraco /homeassistant/components/loqed/ @mikewoudenberg diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py new file mode 100644 index 00000000000..4928d3bb164 --- /dev/null +++ b/homeassistant/components/london_underground/const.py @@ -0,0 +1,26 @@ +"""Constants for the London underground integration.""" +from datetime import timedelta + +DOMAIN = "london_underground" + +CONF_LINE = "line" + + +SCAN_INTERVAL = timedelta(seconds=30) + +TUBE_LINES = [ + "Bakerloo", + "Central", + "Circle", + "District", + "DLR", + "Elizabeth line", + "Hammersmith & City", + "Jubilee", + "London Overground", + "Metropolitan", + "Northern", + "Piccadilly", + "Victoria", + "Waterloo & City", +] diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py new file mode 100644 index 00000000000..a094d099896 --- /dev/null +++ b/homeassistant/components/london_underground/coordinator.py @@ -0,0 +1,30 @@ +"""DataUpdateCoordinator for London underground integration.""" +from __future__ import annotations + +import asyncio +import logging + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class LondonTubeCoordinator(DataUpdateCoordinator): + """London Underground sensor coordinator.""" + + def __init__(self, hass, data): + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._data = data + + async def _async_update_data(self): + async with asyncio.timeout(10): + await self._data.update() + return self._data.data diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index acdb83a2359..eafc63c6ae7 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -1,7 +1,7 @@ { "domain": "london_underground", "name": "London Underground", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/london_underground", "iot_class": "cloud_polling", "loggers": ["london_tube_status"], diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 7e52186fa51..c0d0eeca372 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -1,8 +1,6 @@ """Sensor for checking the status of London Underground tube lines.""" from __future__ import annotations -import asyncio -from datetime import timedelta import logging from london_tube_status import TubeData @@ -15,37 +13,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_LINE, TUBE_LINES +from .coordinator import LondonTubeCoordinator _LOGGER = logging.getLogger(__name__) -DOMAIN = "london_underground" - -CONF_LINE = "line" - - -SCAN_INTERVAL = timedelta(seconds=30) - -TUBE_LINES = [ - "Bakerloo", - "Central", - "Circle", - "District", - "DLR", - "Elizabeth line", - "Hammersmith & City", - "Jubilee", - "London Overground", - "Metropolitan", - "Northern", - "Piccadilly", - "Victoria", - "Waterloo & City", -] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_LINE): vol.All(cv.ensure_list, [vol.In(list(TUBE_LINES))])} ) @@ -76,25 +50,6 @@ async def async_setup_platform( async_add_entities(sensors) -class LondonTubeCoordinator(DataUpdateCoordinator): - """London Underground sensor coordinator.""" - - def __init__(self, hass, data): - """Initialize coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - self._data = data - - async def _async_update_data(self): - async with asyncio.timeout(10): - await self._data.update() - return self._data.data - - class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" From 051e9e7498b7f1d702a78c7a5321b2d828d290d0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:15:02 +0200 Subject: [PATCH 112/984] Use shorthand attributes in Laundrify (#99586) --- .../components/laundrify/binary_sensor.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 81882b68f00..5cca6870b6c 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -51,17 +51,14 @@ class LaundrifyPowerPlug( """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) self._device = device - self._attr_unique_id = device["_id"] - - @property - def device_info(self) -> DeviceInfo: - """Configure the Device of this Entity.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device["_id"])}, - name=self._device["name"], + unique_id = device["_id"] + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device["name"], manufacturer=MANUFACTURER, model=MODEL, - sw_version=self._device["firmwareVersion"], + sw_version=device["firmwareVersion"], ) @property From 890eed11211c496aff9f8b038203b9d9a573954b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:18:55 +0200 Subject: [PATCH 113/984] Use shorthand attributes in LCN (#99587) --- homeassistant/components/lcn/binary_sensor.py | 26 +---- homeassistant/components/lcn/cover.py | 108 ++++++------------ homeassistant/components/lcn/light.py | 40 ++----- homeassistant/components/lcn/sensor.py | 30 ++--- homeassistant/components/lcn/switch.py | 30 ++--- 5 files changed, 70 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 13a2a5b3bb3..ceeeecf50c4 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -66,8 +66,6 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -84,11 +82,6 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): self.setpoint_variable ) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -97,7 +90,7 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): ): return - self._value = input_obj.get_value().is_locked_regulator() + self._attr_is_on = input_obj.get_value().is_locked_regulator() self.async_write_ha_state() @@ -114,8 +107,6 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -132,17 +123,12 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): self.bin_sensor_port ) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusBinSensors): return - self._value = input_obj.get_state(self.bin_sensor_port.value) + self._attr_is_on = input_obj.get_state(self.bin_sensor_port.value) self.async_write_ha_state() @@ -156,7 +142,6 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): super().__init__(config, entry_id, device_connection) self.source = pypck.lcn_defs.Key[config[CONF_DOMAIN_DATA][CONF_SOURCE]] - self._value = None async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -170,11 +155,6 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -186,5 +166,5 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): table_id = ord(self.source.name[0]) - 65 key_id = int(self.source.name[1]) - 1 - self._value = input_obj.get_state(table_id, key_id) + self._attr_is_on = input_obj.get_state(table_id, key_id) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index bc83da55888..31b2dbface0 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -51,6 +51,11 @@ async def async_setup_entry( class LcnOutputsCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to output ports.""" + _attr_is_closed = False + _attr_is_closing = False + _attr_is_opening = False + _attr_assumed_state = True + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -68,10 +73,6 @@ class LcnOutputsCover(LcnEntity, CoverEntity): else: self.reverse_time = None - self._is_closed = False - self._is_closing = False - self._is_opening = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -94,26 +95,6 @@ class LcnOutputsCover(LcnEntity, CoverEntity): pypck.lcn_defs.OutputPort["OUTPUTDOWN"] ) - @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" - return self._is_closed - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return self._is_opening - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return self._is_closing - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True - async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" state = pypck.lcn_defs.MotorStateModifier.DOWN @@ -121,8 +102,8 @@ class LcnOutputsCover(LcnEntity, CoverEntity): state, self.reverse_time ): return - self._is_opening = False - self._is_closing = True + self._attr_is_opening = False + self._attr_is_closing = True self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: @@ -132,9 +113,9 @@ class LcnOutputsCover(LcnEntity, CoverEntity): state, self.reverse_time ): return - self._is_closed = False - self._is_opening = True - self._is_closing = False + self._attr_is_closed = False + self._attr_is_opening = True + self._attr_is_closing = False self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -142,8 +123,8 @@ class LcnOutputsCover(LcnEntity, CoverEntity): state = pypck.lcn_defs.MotorStateModifier.STOP if not await self.device_connection.control_motors_outputs(state): return - self._is_closing = False - self._is_opening = False + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -156,17 +137,17 @@ class LcnOutputsCover(LcnEntity, CoverEntity): if input_obj.get_percent() > 0: # motor is on if input_obj.get_output_id() == self.output_ids[0]: - self._is_opening = True - self._is_closing = False + self._attr_is_opening = True + self._attr_is_closing = False else: # self.output_ids[1] - self._is_opening = False - self._is_closing = True - self._is_closed = self._is_closing + self._attr_is_opening = False + self._attr_is_closing = True + self._attr_is_closed = self._attr_is_closing else: # motor is off # cover is assumed to be closed if we were in closing state before - self._is_closed = self._is_closing - self._is_closing = False - self._is_opening = False + self._attr_is_closed = self._attr_is_closing + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() @@ -174,6 +155,11 @@ class LcnOutputsCover(LcnEntity, CoverEntity): class LcnRelayCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to relays.""" + _attr_is_closed = False + _attr_is_closing = False + _attr_is_opening = False + _attr_assumed_state = True + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -200,34 +186,14 @@ class LcnRelayCover(LcnEntity, CoverEntity): if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.motor) - @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" - return self._is_closed - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return self._is_opening - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return self._is_closing - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True - async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.DOWN if not await self.device_connection.control_motors_relays(states): return - self._is_opening = False - self._is_closing = True + self._attr_is_opening = False + self._attr_is_closing = True self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: @@ -236,9 +202,9 @@ class LcnRelayCover(LcnEntity, CoverEntity): states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP if not await self.device_connection.control_motors_relays(states): return - self._is_closed = False - self._is_opening = True - self._is_closing = False + self._attr_is_closed = False + self._attr_is_opening = True + self._attr_is_closing = False self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -247,8 +213,8 @@ class LcnRelayCover(LcnEntity, CoverEntity): states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP if not await self.device_connection.control_motors_relays(states): return - self._is_closing = False - self._is_opening = False + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -258,11 +224,11 @@ class LcnRelayCover(LcnEntity, CoverEntity): states = input_obj.states # list of boolean values (relay on/off) if states[self.motor_port_onoff]: # motor is on - self._is_opening = not states[self.motor_port_updown] # set direction - self._is_closing = states[self.motor_port_updown] # set direction + self._attr_is_opening = not states[self.motor_port_updown] # set direction + self._attr_is_closing = states[self.motor_port_updown] # set direction else: # motor is off - self._is_opening = False - self._is_closing = False - self._is_closed = states[self.motor_port_updown] + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = states[self.motor_port_updown] self.async_write_ha_state() diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 38480cc3124..65c1344edf0 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -65,6 +65,8 @@ class LcnOutputLight(LcnEntity, LightEntity): """Representation of a LCN light for output ports.""" _attr_supported_features = LightEntityFeature.TRANSITION + _attr_is_on = False + _attr_brightness = 255 def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -79,8 +81,6 @@ class LcnOutputLight(LcnEntity, LightEntity): ) self.dimmable = config[CONF_DOMAIN_DATA][CONF_DIMMABLE] - self._brightness = 255 - self._is_on = False self._is_dimming_to_zero = False if self.dimmable: @@ -101,16 +101,6 @@ class LcnOutputLight(LcnEntity, LightEntity): if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if ATTR_BRIGHTNESS in kwargs: @@ -128,7 +118,7 @@ class LcnOutputLight(LcnEntity, LightEntity): self.output.value, percent, transition ): return - self._is_on = True + self._attr_is_on = True self._is_dimming_to_zero = False self.async_write_ha_state() @@ -146,7 +136,7 @@ class LcnOutputLight(LcnEntity, LightEntity): ): return self._is_dimming_to_zero = bool(transition) - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -157,11 +147,11 @@ class LcnOutputLight(LcnEntity, LightEntity): ): return - self._brightness = int(input_obj.get_percent() / 100.0 * 255) - if self.brightness == 0: + self._attr_brightness = int(input_obj.get_percent() / 100.0 * 255) + if self._attr_brightness == 0: self._is_dimming_to_zero = False - if not self._is_dimming_to_zero and self.brightness is not None: - self._is_on = self.brightness > 0 + if not self._is_dimming_to_zero and self._attr_brightness is not None: + self._attr_is_on = self._attr_brightness > 0 self.async_write_ha_state() @@ -170,6 +160,7 @@ class LcnRelayLight(LcnEntity, LightEntity): _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_is_on = False def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -179,8 +170,6 @@ class LcnRelayLight(LcnEntity, LightEntity): self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -193,18 +182,13 @@ class LcnRelayLight(LcnEntity, LightEntity): if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON if not await self.device_connection.control_relays(states): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -213,7 +197,7 @@ class LcnRelayLight(LcnEntity, LightEntity): states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF if not await self.device_connection.control_relays(states): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -221,5 +205,5 @@ class LcnRelayLight(LcnEntity, LightEntity): if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return - self._is_on = input_obj.get_state(self.output.value) + self._attr_is_on = input_obj.get_state(self.output.value) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 66321c79a1b..1428019b59f 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -77,8 +77,7 @@ class LcnVariableSensor(LcnEntity, SensorEntity): self.unit = pypck.lcn_defs.VarUnit.parse( config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT] ) - - self._value = None + self._attr_native_unit_of_measurement = cast(str, self.unit.value) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -92,16 +91,6 @@ class LcnVariableSensor(LcnEntity, SensorEntity): if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.variable) - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self._value - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return cast(str, self.unit.value) - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -110,7 +99,7 @@ class LcnVariableSensor(LcnEntity, SensorEntity): ): return - self._value = input_obj.get_value().to_var_unit(self.unit) + self._attr_native_value = input_obj.get_value().to_var_unit(self.unit) self.async_write_ha_state() @@ -130,8 +119,6 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity): config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -144,19 +131,18 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity): if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusLedsAndLogicOps): return if self.source in pypck.lcn_defs.LedPort: - self._value = input_obj.get_led_state(self.source.value).name.lower() + self._attr_native_value = input_obj.get_led_state( + self.source.value + ).name.lower() elif self.source in pypck.lcn_defs.LogicOpPort: - self._value = input_obj.get_logic_op_state(self.source.value).name.lower() + self._attr_native_value = input_obj.get_logic_op_state( + self.source.value + ).name.lower() self.async_write_ha_state() diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index ded15c0f1da..8374ff85ab7 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -52,6 +52,8 @@ async def async_setup_entry( class LcnOutputSwitch(LcnEntity, SwitchEntity): """Representation of a LCN switch for output ports.""" + _attr_is_on = False + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -60,8 +62,6 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -74,23 +74,18 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if not await self.device_connection.dim_output(self.output.value, 100, 0): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" if not await self.device_connection.dim_output(self.output.value, 0, 0): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -101,13 +96,15 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): ): return - self._is_on = input_obj.get_percent() > 0 + self._attr_is_on = input_obj.get_percent() > 0 self.async_write_ha_state() class LcnRelaySwitch(LcnEntity, SwitchEntity): """Representation of a LCN switch for relay ports.""" + _attr_is_on = False + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -116,8 +113,6 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -130,18 +125,13 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON if not await self.device_connection.control_relays(states): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -150,7 +140,7 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF if not await self.device_connection.control_relays(states): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -158,5 +148,5 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return - self._is_on = input_obj.get_state(self.output.value) + self._attr_is_on = input_obj.get_state(self.output.value) self.async_write_ha_state() From 8ea3b877f651fc5dc207cda4b56320043d7ef48c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:21:30 +0200 Subject: [PATCH 114/984] Use shorthand attributes in Juicenet (#99575) --- homeassistant/components/juicenet/entity.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index 3c325715c82..b3433948582 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -23,20 +23,12 @@ class JuiceNetDevice(CoordinatorEntity): super().__init__(coordinator) self.device = device self.key = key - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self.device.id}-{self.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this JuiceNet Device.""" - return DeviceInfo( + self._attr_unique_id = f"{device.id}-{key}" + self._attr_device_info = DeviceInfo( configuration_url=( - f"https://home.juice.net/Portal/Details?unitID={self.device.id}" + f"https://home.juice.net/Portal/Details?unitID={device.id}" ), - identifiers={(DOMAIN, self.device.id)}, + identifiers={(DOMAIN, device.id)}, manufacturer="JuiceNet", - name=self.device.name, + name=device.name, ) From cf2d3674b9fe16a11ab823fdcf52de41288cbf1c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:29:56 +0200 Subject: [PATCH 115/984] Use shorthand attributes in Kulersky (#99583) --- homeassistant/components/kulersky/light.py | 41 ++++++++-------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index c68633ab639..6636bfdba9f 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -66,13 +66,19 @@ class KulerskyLight(LightEntity): _attr_has_entity_name = True _attr_name = None + _attr_available = False + _attr_supported_color_modes = {ColorMode.RGBW} + _attr_color_mode = ColorMode.RGBW def __init__(self, light: pykulersky.Light) -> None: """Initialize a Kuler Sky light.""" self._light = light - self._available = False - self._attr_supported_color_modes = {ColorMode.RGBW} - self._attr_color_mode = ColorMode.RGBW + self._attr_unique_id = light.address + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, light.address)}, + manufacturer="Brightech", + name=light.name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -91,30 +97,11 @@ class KulerskyLight(LightEntity): "Exception disconnected from %s", self._light.address, exc_info=True ) - @property - def unique_id(self): - """Return the ID of this light.""" - return self._light.address - - @property - def device_info(self) -> DeviceInfo: - """Device info for this light.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Brightech", - name=self._light.name, - ) - @property def is_on(self): """Return true if light is on.""" return self.brightness > 0 - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" default_rgbw = (255,) * 4 if self.rgbw_color is None else self.rgbw_color @@ -140,18 +127,18 @@ class KulerskyLight(LightEntity): async def async_update(self) -> None: """Fetch new state data for this light.""" try: - if not self._available: + if not self._attr_available: await self._light.connect() rgbw = await self._light.get_color() except pykulersky.PykulerskyException as exc: - if self._available: + if self._attr_available: _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc) - self._available = False + self._attr_available = False return - if self._available is False: + if self._attr_available is False: _LOGGER.info("Reconnected to %s", self._light.address) - self._available = True + self._attr_available = True brightness = max(rgbw) if not brightness: self._attr_rgbw_color = (0, 0, 0, 0) From 6194f7faea6f53ab131a64ff783e254301f59495 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:39:24 +0200 Subject: [PATCH 116/984] Bump pyenphase to 1.9.1 (#99574) --- 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 540c121bb17..a45f4f01e49 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.8.1"], + "requirements": ["pyenphase==1.9.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index c70cbe30d5f..f2369f1cf7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1673,7 +1673,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.8.1 +pyenphase==1.9.1 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 487b2ecf4b7..c3adc7ddf98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1240,7 +1240,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.8.1 +pyenphase==1.9.1 # homeassistant.components.everlights pyeverlights==0.1.0 From d5301fba90f6c2a7633a89d2648eaec6023788b4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:50:05 +0200 Subject: [PATCH 117/984] Use shorthand attributes in Keenetic (#99577) --- .../components/keenetic_ndms2/binary_sensor.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py index f39c92519e4..ab0b3370197 100644 --- a/homeassistant/components/keenetic_ndms2/binary_sensor.py +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -33,22 +33,14 @@ class RouterOnlineBinarySensor(BinarySensorEntity): def __init__(self, router: KeeneticRouter) -> None: """Initialize the APCUPSd binary device.""" self._router = router - - @property - def unique_id(self) -> str: - """Return a unique identifier for this device.""" - return f"online_{self._router.config_entry.entry_id}" + self._attr_unique_id = f"online_{router.config_entry.entry_id}" + self._attr_device_info = router.device_info @property def is_on(self): """Return true if the UPS is online, else false.""" return self._router.available - @property - def device_info(self): - """Return a client description for device registry.""" - return self._router.device_info - async def async_added_to_hass(self) -> None: """Client entity created.""" self.async_on_remove( From f3d8a0eaafd3083fb028777b1fcc338555f3bb15 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:08:38 +0200 Subject: [PATCH 118/984] Don't set assumed_state in cover groups (#99391) --- homeassistant/components/group/cover.py | 20 +------------------- tests/components/group/test_cover.py | 13 +++++++------ 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index dbb49222bb0..d22184c0922 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -17,7 +17,6 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, @@ -44,7 +43,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity -from .util import attribute_equal, reduce_attribute +from .util import reduce_attribute KEY_OPEN_CLOSE = "open_close" KEY_STOP = "stop" @@ -116,7 +115,6 @@ class CoverGroup(GroupEntity, CoverEntity): _attr_is_opening: bool | None = False _attr_is_closing: bool | None = False _attr_current_cover_position: int | None = 100 - _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a CoverGroup entity.""" @@ -251,8 +249,6 @@ class CoverGroup(GroupEntity, CoverEntity): @callback def async_update_group_state(self) -> None: """Update state and attributes.""" - self._attr_assumed_state = False - states = [ state.state for entity_id in self._entity_ids @@ -293,9 +289,6 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_current_cover_position = reduce_attribute( position_states, ATTR_CURRENT_POSITION ) - self._attr_assumed_state |= not attribute_equal( - position_states, ATTR_CURRENT_POSITION - ) tilt_covers = self._tilts[KEY_POSITION] all_tilt_states = [self.hass.states.get(x) for x in tilt_covers] @@ -303,9 +296,6 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_current_cover_tilt_position = reduce_attribute( tilt_states, ATTR_CURRENT_TILT_POSITION ) - self._attr_assumed_state |= not attribute_equal( - tilt_states, ATTR_CURRENT_TILT_POSITION - ) supported_features = CoverEntityFeature(0) if self._covers[KEY_OPEN_CLOSE]: @@ -322,11 +312,3 @@ class CoverGroup(GroupEntity, CoverEntity): if self._tilts[KEY_POSITION]: supported_features |= CoverEntityFeature.SET_TILT_POSITION self._attr_supported_features = supported_features - - if not self._attr_assumed_state: - for entity_id in self._entity_ids: - if (state := self.hass.states.get(entity_id)) is None: - continue - if state and state.attributes.get(ATTR_ASSUMED_STATE): - self._attr_assumed_state = True - break diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 84ccba2ff66..4e0ddc19a31 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -346,10 +346,10 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_CURRENT_POSITION] == 70 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 - # ### Test assumed state ### + # ### Test state when group members have different states ### # ########################## - # For covers - assumed state set true if position differ + # Covers hass.states.async_set( DEMO_COVER, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 4, ATTR_CURRENT_POSITION: 100} ) @@ -357,7 +357,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 244 assert state.attributes[ATTR_CURRENT_POSITION] == 85 # (70 + 100) / 2 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 @@ -373,7 +373,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 - # For tilts - assumed state set true if tilt position differ + # Tilts hass.states.async_set( DEMO_TILT, STATE_OPEN, @@ -383,7 +383,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 128 assert ATTR_CURRENT_POSITION not in state.attributes assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 80 # (60 + 100) / 2 @@ -399,11 +399,12 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes + # Group member has set assumed_state hass.states.async_set(DEMO_TILT, STATE_CLOSED, {ATTR_ASSUMED_STATE: True}) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes # Test entity registry integration entity_registry = er.async_get(hass) From 6223af189988cee90f9b78fa71cb5007c54c53dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:08:50 +0200 Subject: [PATCH 119/984] Don't set assumed_state in fan groups (#99399) --- homeassistant/components/group/fan.py | 18 +----------------- tests/components/group/test_config_flow.py | 2 +- tests/components/group/test_fan.py | 17 ++++------------- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 4ee788c8402..4e3bb824266 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -25,7 +25,6 @@ from homeassistant.components.fan import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, @@ -41,12 +40,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity -from .util import ( - attribute_equal, - most_frequent_attribute, - reduce_attribute, - states_equal, -) +from .util import attribute_equal, most_frequent_attribute, reduce_attribute SUPPORTED_FLAGS = { FanEntityFeature.SET_SPEED, @@ -110,7 +104,6 @@ class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" _attr_available: bool = False - _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a FanGroup entity.""" @@ -243,19 +236,16 @@ class FanGroup(GroupEntity, FanEntity): """Set an attribute based on most frequent supported entities attributes.""" states = self._async_states_by_support_flag(flag) setattr(self, attr, most_frequent_attribute(states, entity_attr)) - self._attr_assumed_state |= not attribute_equal(states, entity_attr) @callback def async_update_group_state(self) -> None: """Update state and attributes.""" - self._attr_assumed_state = False states = [ state for entity_id in self._entity_ids if (state := self.hass.states.get(entity_id)) is not None ] - self._attr_assumed_state |= not states_equal(states) # Set group as unavailable if all members are unavailable or missing self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) @@ -274,9 +264,6 @@ class FanGroup(GroupEntity, FanEntity): FanEntityFeature.SET_SPEED ) self._percentage = reduce_attribute(percentage_states, ATTR_PERCENTAGE) - self._attr_assumed_state |= not attribute_equal( - percentage_states, ATTR_PERCENTAGE - ) if ( percentage_states and percentage_states[0].attributes.get(ATTR_PERCENTAGE_STEP) @@ -301,6 +288,3 @@ class FanGroup(GroupEntity, FanEntity): ior, [feature for feature in SUPPORTED_FLAGS if self._fans[feature]], 0 ) ) - self._attr_assumed_state |= any( - state.attributes.get(ATTR_ASSUMED_STATE) for state in states - ) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index d0e90fe61bd..1c8275c7f2d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -468,7 +468,7 @@ async def test_options_flow_hides_members( COVER_ATTRS = [{"supported_features": 0}, {}] EVENT_ATTRS = [{"event_types": []}, {"event_type": None}] -FAN_ATTRS = [{"supported_features": 0}, {"assumed_state": True}] +FAN_ATTRS = [{"supported_features": 0}, {}] LIGHT_ATTRS = [ { "icon": "mdi:lightbulb-group", diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 6269df3fed7..2272a29f6ed 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -247,11 +247,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_PERCENTAGE] == 50 assert ATTR_ASSUMED_STATE not in state.attributes - # Add Entity that supports - # ### Test assumed state ### - # ########################## - - # Add Entity with a different speed should set assumed state + # Add Entity with a different speed should not set assumed state hass.states.async_set( PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, @@ -264,7 +260,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_PERCENTAGE] == int((50 + 75) / 2) @@ -306,11 +302,7 @@ async def test_direction_oscillating(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD assert ATTR_ASSUMED_STATE not in state.attributes - # Add Entity that supports - # ### Test assumed state ### - # ########################## - - # Add Entity with a different direction should set assumed state + # Add Entity with a different direction should not set assumed state hass.states.async_set( PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_ON, @@ -325,11 +317,10 @@ async def test_direction_oscillating(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert ATTR_PERCENTAGE in state.attributes assert state.attributes[ATTR_PERCENTAGE] == 50 assert state.attributes[ATTR_OSCILLATING] is True - assert ATTR_ASSUMED_STATE in state.attributes # Now that everything is the same, no longer assumed state From 709ce7e0af91c2bccb1299b4d060ad176e6b3a4b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:09:51 +0200 Subject: [PATCH 120/984] Set state of entity with invalid state to unknown (#99452) * Set state of entity with invalid state to unknown * Add test * Apply suggestions from code review Co-authored-by: Robert Resch * Update test_entity.py --------- Co-authored-by: Robert Resch --- homeassistant/helpers/entity.py | 16 ++++++++++++++-- tests/helpers/test_entity.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 29a944874ab..e946c41d3b8 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -35,7 +35,11 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError +from homeassistant.exceptions import ( + HomeAssistantError, + InvalidStateError, + NoEntitySpecifiedError, +) from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify @@ -848,7 +852,15 @@ class Entity(ABC): self._context = None self._context_set = None - hass.states.async_set(entity_id, state, attr, self.force_update, self._context) + try: + hass.states.async_set( + entity_id, state, attr, self.force_update, self._context + ) + except InvalidStateError: + _LOGGER.exception("Failed to set state, fall back to %s", STATE_UNKNOWN) + hass.states.async_set( + entity_id, STATE_UNKNOWN, {}, self.force_update, self._context + ) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 200b0230adb..20bea6a98eb 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta +import logging import threading from typing import Any from unittest.mock import MagicMock, PropertyMock, patch @@ -1477,3 +1478,30 @@ async def test_warn_no_platform( caplog.clear() ent.async_write_ha_state() assert error_message not in caplog.text + + +async def test_invalid_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the entity helper catches InvalidState and sets state to unknown.""" + ent = entity.Entity() + ent.entity_id = "test.test" + ent.hass = hass + + ent._attr_state = "x" * 255 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == "x" * 255 + + caplog.clear() + ent._attr_state = "x" * 256 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == STATE_UNKNOWN + assert ( + "homeassistant.helpers.entity", + logging.ERROR, + f"Failed to set state, fall back to {STATE_UNKNOWN}", + ) in caplog.record_tuples + + ent._attr_state = "x" * 255 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == "x" * 255 From 7c595ee2da562b909fcbbd4b30ce11126cc5f58f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:10:43 +0200 Subject: [PATCH 121/984] Validate state in template helper preview (#99455) * Validate state in template helper preview * Deduplicate state validation --- .../components/template/template_entity.py | 13 ++++++++++--- homeassistant/core.py | 16 +++++++++++----- tests/test_core.py | 7 +++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index ac06e2c8734..c33674fa86f 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -25,6 +25,7 @@ from homeassistant.core import ( HomeAssistant, State, callback, + validate_state, ) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv @@ -413,8 +414,8 @@ class TemplateEntity(Entity): return for update in updates: - for attr in self._template_attrs[update.template]: - attr.handle_result( + for template_attr in self._template_attrs[update.template]: + template_attr.handle_result( event, update.template, update.last_result, update.result ) @@ -422,7 +423,13 @@ class TemplateEntity(Entity): self.async_write_ha_state() return - self._preview_callback(*self._async_generate_attributes(), None) + try: + state, attrs = self._async_generate_attributes() + validate_state(state) + except Exception as err: # pylint: disable=broad-exception-caught + self._preview_callback(None, None, str(err)) + else: + self._preview_callback(state, attrs, None) @callback def _async_template_startup(self, *_: Any) -> None: diff --git a/homeassistant/core.py b/homeassistant/core.py index bd596780759..47a8119de71 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -174,6 +174,16 @@ def valid_entity_id(entity_id: str) -> bool: return VALID_ENTITY_ID.match(entity_id) is not None +def validate_state(state: str) -> str: + """Validate a state, raise if it not valid.""" + if len(state) > MAX_LENGTH_STATE_STATE: + raise InvalidStateError( + f"Invalid state with length {len(state)}. " + "State max length is 255 characters." + ) + return state + + def callback(func: _CallableT) -> _CallableT: """Annotation to mark method as safe to call from within the event loop.""" setattr(func, "_hass_callback", True) @@ -1255,11 +1265,7 @@ class State: "Format should be ." ) - if len(state) > MAX_LENGTH_STATE_STATE: - raise InvalidStateError( - f"Invalid state encountered for entity ID: {entity_id}. " - "State max length is 255 characters." - ) + validate_state(state) self.entity_id = entity_id self.state = state diff --git a/tests/test_core.py b/tests/test_core.py index f4a80468050..5dcbb81db68 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2465,3 +2465,10 @@ async def test_cancellable_hassjob(hass: HomeAssistant) -> None: # Cleanup timer2.cancel() + + +async def test_validate_state(hass: HomeAssistant) -> None: + """Test validate_state.""" + assert ha.validate_state("test") == "test" + with pytest.raises(InvalidStateError): + ha.validate_state("t" * 256) From 7643820e5919f2817e393d7bac4e83443d11fa93 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:12:33 +0200 Subject: [PATCH 122/984] Add loader.async_get_loaded_integration (#99440) * Add loader.async_get_loaded_integration * Decorate async_get_loaded_integration with @callback --- homeassistant/core.py | 5 ++++- homeassistant/loader.py | 27 ++++++++++++++++++++++++++- tests/test_loader.py | 7 +++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 47a8119de71..3648fca99f7 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -32,7 +32,7 @@ from urllib.parse import urlparse import voluptuous as vol import yarl -from . import block_async_io, loader, util +from . import block_async_io, util from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -310,6 +310,9 @@ class HomeAssistant: def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" + # pylint: disable-next=import-outside-toplevel + from . import loader + self.loop = asyncio.get_running_loop() self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 40161bd3be9..8906cefb241 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,6 +25,7 @@ from awesomeversion import ( import voluptuous as vol from . import generated +from .core import HomeAssistant, callback from .generated.application_credentials import APPLICATION_CREDENTIALS from .generated.bluetooth import BLUETOOTH from .generated.dhcp import DHCP @@ -37,7 +38,6 @@ from .util.json import JSON_DECODE_EXCEPTIONS, json_loads # Typing imports that create a circular dependency if TYPE_CHECKING: from .config_entries import ConfigEntry - from .core import HomeAssistant from .helpers import device_registry as dr from .helpers.typing import ConfigType @@ -875,6 +875,22 @@ def _resolve_integrations_from_root( return integrations +@callback +def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integration: + """Get an integration which is already loaded. + + Raises IntegrationNotLoaded if the integration is not loaded. + """ + cache = hass.data[DATA_INTEGRATIONS] + if TYPE_CHECKING: + cache = cast(dict[str, Integration | asyncio.Future[None]], cache) + int_or_fut = cache.get(domain, _UNDEF) + # Integration is never subclassed, so we can check for type + if type(int_or_fut) is Integration: # noqa: E721 + return int_or_fut + raise IntegrationNotLoaded(domain) + + async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration: """Get integration.""" integrations_or_excs = await async_get_integrations(hass, [domain]) @@ -970,6 +986,15 @@ class IntegrationNotFound(LoaderError): self.domain = domain +class IntegrationNotLoaded(LoaderError): + """Raised when a component is not loaded.""" + + def __init__(self, domain: str) -> None: + """Initialize a component not found error.""" + super().__init__(f"Integration '{domain}' not loaded.") + self.domain = domain + + class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" diff --git a/tests/test_loader.py b/tests/test_loader.py index 6e62be08f66..b62e25b79e3 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -150,10 +150,17 @@ async def test_custom_integration_version_not_valid( async def test_get_integration(hass: HomeAssistant) -> None: """Test resolving integration.""" + with pytest.raises(loader.IntegrationNotLoaded): + loader.async_get_loaded_integration(hass, "hue") + integration = await loader.async_get_integration(hass, "hue") assert hue == integration.get_component() assert hue_light == integration.get_platform("light") + integration = loader.async_get_loaded_integration(hass, "hue") + assert hue == integration.get_component() + assert hue_light == integration.get_platform("light") + async def test_get_integration_exceptions(hass: HomeAssistant) -> None: """Test resolving integration.""" From 3b6811dab67389695caa11540e4caa0e8df27085 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 4 Sep 2023 15:59:18 +0300 Subject: [PATCH 123/984] Use `CONF_SALT` correctly in config_flow validation (#99597) --- homeassistant/components/simplepush/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py index 702be4391e4..d87f6fa1913 100644 --- a/homeassistant/components/simplepush/config_flow.py +++ b/homeassistant/components/simplepush/config_flow.py @@ -20,7 +20,7 @@ def validate_input(entry: dict[str, str]) -> dict[str, str] | None: send( key=entry[CONF_DEVICE_KEY], password=entry[CONF_PASSWORD], - salt=entry[CONF_PASSWORD], + salt=entry[CONF_SALT], title="HA test", message="Message delivered successfully", ) From cab0bde37b8f2ae0b2b575a7996d954b16377693 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:05:33 +0200 Subject: [PATCH 124/984] Use shorthand attributes in Lyric (#99593) --- homeassistant/components/lyric/climate.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index ef662d061e8..1522f167a4a 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -139,6 +139,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): entity_description: ClimateEntityDescription _attr_name = None + _attr_preset_modes = [ + PRESET_NO_HOLD, + PRESET_HOLD_UNTIL, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION_HOLD, + ] def __init__( self, @@ -245,17 +252,6 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): """Return current preset mode.""" return self.device.changeableValues.thermostatSetpointStatus - @property - def preset_modes(self) -> list[str] | None: - """Return preset modes.""" - return [ - PRESET_NO_HOLD, - PRESET_HOLD_UNTIL, - PRESET_PERMANENT_HOLD, - PRESET_TEMPORARY_HOLD, - PRESET_VACATION_HOLD, - ] - @property def min_temp(self) -> float: """Identify min_temp in Lyric API or defaults if not available.""" From f1bb7c25db1dcc2f06717add2772d6989bf18750 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:18:22 +0200 Subject: [PATCH 125/984] Use shorthand attributes in Motion eye (#99596) --- homeassistant/components/motioneye/camera.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 683308e081c..fd3f0ec86c0 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -143,6 +143,10 @@ async def async_setup_entry( class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): """motionEye mjpeg camera.""" + _attr_brand = MOTIONEYE_MANUFACTURER + # motionEye cameras are always streaming or unavailable. + _attr_is_streaming = True + def __init__( self, config_entry_id: str, @@ -158,9 +162,6 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): self._surveillance_password = password self._motion_detection_enabled: bool = camera.get(KEY_MOTION_DETECTION, False) - # motionEye cameras are always streaming or unavailable. - self._attr_is_streaming = True - MotionEyeEntity.__init__( self, config_entry_id, @@ -249,11 +250,6 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): ) super()._handle_coordinator_update() - @property - def brand(self) -> str: - """Return the camera brand.""" - return MOTIONEYE_MANUFACTURER - @property def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" From 8dc05894a8c45c6a6362520e5b2e1dd0392a554a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:18:48 +0200 Subject: [PATCH 126/984] Use shorthand attributes in Nanoleaf (#99601) --- homeassistant/components/nanoleaf/light.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index f0425594763..dc251ac1e5d 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -47,6 +47,7 @@ class NanoleafLight(NanoleafEntity, LightEntity): _attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION _attr_name = None + _attr_icon = "mdi:triangle-outline" def __init__( self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] @@ -83,11 +84,6 @@ class NanoleafLight(NanoleafEntity, LightEntity): """Return the list of supported effects.""" return self._nanoleaf.effects_list - @property - def icon(self) -> str: - """Return the icon to use in the frontend, if any.""" - return "mdi:triangle-outline" - @property def is_on(self) -> bool: """Return true if light is on.""" From c225ee89d6db56744e8d7c7550fec093188ef729 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 4 Sep 2023 09:26:14 -0400 Subject: [PATCH 127/984] Bump ZHA dependencies (#99561) --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 809b576defa..7352487a318 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,12 +21,12 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.1", + "bellows==0.36.2", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.103", "zigpy-deconz==0.21.0", - "zigpy==0.57.0", + "zigpy==0.57.1", "zigpy-xbee==0.18.1", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.4", diff --git a/requirements_all.txt b/requirements_all.txt index f2369f1cf7f..7cbd80a163c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,7 +510,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.1 +bellows==0.36.2 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -2795,7 +2795,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.57.0 +zigpy==0.57.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3adc7ddf98..b295f0dc7e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -431,7 +431,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.1 +bellows==0.36.2 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -2062,7 +2062,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.57.0 +zigpy==0.57.1 # homeassistant.components.zwave_js zwave-js-server-python==0.51.0 From 799d0e591c763f7b961b9c1481894c0ed5ff761b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:29:30 +0200 Subject: [PATCH 128/984] Remove unneeded name property from Logi Circle (#99604) --- homeassistant/components/logi_circle/camera.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 77c0f2f24c8..5c27d2a08ae 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -122,11 +122,6 @@ class LogiCam(Camera): """Return a unique ID.""" return self._id - @property - def name(self): - """Return the name of this camera.""" - return self._name - @property def device_info(self) -> DeviceInfo: """Return information about the device.""" From 29664d04d069fcb80ca3910572c609add6992830 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:31:33 +0200 Subject: [PATCH 129/984] Use shorthand attributes in Mutesync (#99600) --- .../components/mutesync/binary_sensor.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 444643d5333..910f91fc4c6 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -36,24 +36,17 @@ class MuteStatus(update_coordinator.CoordinatorEntity, BinarySensorEntity): super().__init__(coordinator) self._sensor_type = sensor_type self._attr_translation_key = sensor_type - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return f"{self.coordinator.data['user-id']}-{self._sensor_type}" + user_id = coordinator.data["user-id"] + self._attr_unique_id = f"{user_id}-{sensor_type}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, user_id)}, + manufacturer="mütesync", + model="mutesync app", + name="mutesync", + ) @property def is_on(self): """Return the state of the sensor.""" return self.coordinator.data[self._sensor_type] - - @property - def device_info(self) -> DeviceInfo: - """Return the device info of the sensor.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.coordinator.data["user-id"])}, - manufacturer="mütesync", - model="mutesync app", - name="mutesync", - ) From aa943b7103c0fc4847fbc2db7190f78c3b5e4eeb Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 4 Sep 2023 16:21:55 +0200 Subject: [PATCH 130/984] Bumb python-homewizard-energy to 2.1.0 (#99598) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 36b9631c801..8930ec90ebf 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==2.0.2"], + "requirements": ["python-homewizard-energy==2.1.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7cbd80a163c..99d685b7aa4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2109,7 +2109,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==2.0.2 +python-homewizard-energy==2.1.0 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b295f0dc7e8..c6a6a19a809 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1556,7 +1556,7 @@ python-ecobee-api==0.2.14 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==2.0.2 +python-homewizard-energy==2.1.0 # homeassistant.components.izone python-izone==1.2.9 From f2e0ff4f0f2126daaf8ba97e5b613729200a818a Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 4 Sep 2023 17:24:20 +0300 Subject: [PATCH 131/984] Bump simplepush api to 2.2.3 (#99599) --- homeassistant/components/simplepush/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplepush/manifest.json b/homeassistant/components/simplepush/manifest.json index 25f53a9617c..5b792072f44 100644 --- a/homeassistant/components/simplepush/manifest.json +++ b/homeassistant/components/simplepush/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/simplepush", "iot_class": "cloud_polling", "loggers": ["simplepush"], - "requirements": ["simplepush==2.1.1"] + "requirements": ["simplepush==2.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 99d685b7aa4..a0efbb697b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2404,7 +2404,7 @@ shodan==1.28.0 simplehound==0.3 # homeassistant.components.simplepush -simplepush==2.1.1 +simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2023.08.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6a6a19a809..705d67b73b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1761,7 +1761,7 @@ sharkiq==1.0.2 simplehound==0.3 # homeassistant.components.simplepush -simplepush==2.1.1 +simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2023.08.0 From 22e90a5755ec3bcb99ab83c7e9ce44f9344a3150 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 17:53:33 +0200 Subject: [PATCH 132/984] Remove default state from Nibe (#99611) Remove start state --- homeassistant/components/nibe_heatpump/number.py | 1 - homeassistant/components/nibe_heatpump/switch.py | 1 - 2 files changed, 2 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 79078811881..1b3bc928985 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -56,7 +56,6 @@ class Number(CoilEntity, NumberEntity): self._attr_native_step = 1 / coil.factor self._attr_native_unit_of_measurement = coil.unit - self._attr_native_value = None def _async_read_coil(self, data: CoilData) -> None: if data.value is None: diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 95d96de9764..16a7ef2b1f5 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -38,7 +38,6 @@ class Switch(CoilEntity, SwitchEntity): def __init__(self, coordinator: Coordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) - self._attr_is_on = None def _async_read_coil(self, data: CoilData) -> None: self._attr_is_on = data.value == "ON" From 2391087836535a96971371573a4529df96672d29 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 18:50:33 +0200 Subject: [PATCH 133/984] Use shorthand attributes in Nest (#99606) --- homeassistant/components/nest/camera.py | 29 +++++-------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 90c4056161e..c943ea922e9 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -24,7 +24,6 @@ from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME from homeassistant.config_entries import ConfigEntry 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 homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow @@ -68,7 +67,10 @@ class NestCamera(Camera): """Initialize the camera.""" super().__init__() self._device = device - self._device_info = NestDeviceInfo(device) + nest_device_info = NestDeviceInfo(device) + self._attr_device_info = nest_device_info.device_info + self._attr_brand = nest_device_info.device_brand + self._attr_model = nest_device_info.device_model self._stream: RtspStream | None = None self._create_stream_url_lock = asyncio.Lock() self._stream_refresh_unsub: Callable[[], None] | None = None @@ -84,33 +86,14 @@ class NestCamera(Camera): if StreamingProtocol.RTSP in trait.supported_protocols: self._rtsp_live_stream_trait = trait self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 + # The API "name" field is a unique device identifier. + self._attr_unique_id = f"{self._device.name}-camera" @property def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" return self._rtsp_live_stream_trait is not None - @property - def unique_id(self) -> str: - """Return a unique ID.""" - # The API "name" field is a unique device identifier. - return f"{self._device.name}-camera" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return self._device_info.device_info - - @property - def brand(self) -> str | None: - """Return the camera brand.""" - return self._device_info.device_brand - - @property - def model(self) -> str | None: - """Return the camera model.""" - return self._device_info.device_model - @property def frontend_stream_type(self) -> StreamType | None: """Return the type of stream supported by this camera.""" From cb5d4ee6fa4f56ecd877938137d1aa75b209edab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 19:00:19 +0200 Subject: [PATCH 134/984] Use shorthand attributes in Octoprint (#99623) --- homeassistant/components/octoprint/binary_sensor.py | 6 +----- homeassistant/components/octoprint/button.py | 7 +------ homeassistant/components/octoprint/sensor.py | 6 +----- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index b0e43bd74e0..0bc13f66415 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -52,11 +52,7 @@ class OctoPrintBinarySensorBase( self._device_id = device_id self._attr_name = f"OctoPrint {sensor_type}" self._attr_unique_id = f"{sensor_type}-{device_id}" - - @property - def device_info(self): - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info @property def is_on(self): diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 578554da5bd..b2c1672b3e4 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -5,7 +5,6 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry 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 homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -53,11 +52,7 @@ class OctoprintButton(CoordinatorEntity[OctoprintDataUpdateCoordinator], ButtonE self._device_id = device_id self._attr_name = f"OctoPrint {button_type}" self._attr_unique_id = f"{button_type}-{device_id}" - - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info @property def available(self) -> bool: diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 17bea7b8ac5..1ea29c2b4e8 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -104,11 +104,7 @@ class OctoPrintSensorBase( self._device_id = device_id self._attr_name = f"OctoPrint {sensor_type}" self._attr_unique_id = f"{sensor_type}-{device_id}" - - @property - def device_info(self): - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info class OctoPrintStatusSensor(OctoPrintSensorBase): From 4812b21ffdef6c1224b14c754b1809675256c6bb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 19:28:44 +0200 Subject: [PATCH 135/984] Remove slugify from tomorrowio unique id (#99006) --- homeassistant/components/tomorrowio/sensor.py | 89 ++++++++++++------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 119a3dfe582..cd48af8536a 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -35,7 +35,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -80,6 +79,7 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): # restrict the type to str. name: str = "" + attribute: str = "" unit_imperial: str | None = None unit_metric: str | None = None multiplication_factor: Callable[[float], float] | float | None = None @@ -110,13 +110,15 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa SENSOR_TYPES = ( TomorrowioSensorEntityDescription( - key=TMRW_ATTR_FEELS_LIKE, + key="feels_like", + attribute=TMRW_ATTR_FEELS_LIKE, name="Feels Like", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_DEW_POINT, + key="dew_point", + attribute=TMRW_ATTR_DEW_POINT, name="Dew Point", icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -124,7 +126,8 @@ SENSOR_TYPES = ( ), # Data comes in as hPa TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + key="pressure_surface_level", + attribute=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, name="Pressure (Surface Level)", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -132,7 +135,8 @@ SENSOR_TYPES = ( # Data comes in as W/m^2, convert to BTUs/(hr * ft^2) for imperial # https://www.theunitconverter.com/watt-square-meter-to-btu-hour-square-foot-conversion/ TomorrowioSensorEntityDescription( - key=TMRW_ATTR_SOLAR_GHI, + key="global_horizontal_irradiance", + attribute=TMRW_ATTR_SOLAR_GHI, name="Global Horizontal Irradiance", unit_imperial=UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, unit_metric=UnitOfIrradiance.WATTS_PER_SQUARE_METER, @@ -141,7 +145,8 @@ SENSOR_TYPES = ( ), # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_BASE, + key="cloud_base", + attribute=TMRW_ATTR_CLOUD_BASE, name="Cloud Base", icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, @@ -154,7 +159,8 @@ SENSOR_TYPES = ( ), # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_CEILING, + key="cloud_ceiling", + attribute=TMRW_ATTR_CLOUD_CEILING, name="Cloud Ceiling", icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, @@ -166,14 +172,16 @@ SENSOR_TYPES = ( ), ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_COVER, + key="cloud_cover", + attribute=TMRW_ATTR_CLOUD_COVER, name="Cloud Cover", icon="mdi:cloud-percent", native_unit_of_measurement=PERCENTAGE, ), # Data comes in as m/s, convert to mi/h for imperial TomorrowioSensorEntityDescription( - key=TMRW_ATTR_WIND_GUST, + key="wind_gust", + attribute=TMRW_ATTR_WIND_GUST, name="Wind Gust", icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, @@ -183,7 +191,8 @@ SENSOR_TYPES = ( ), ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PRECIPITATION_TYPE, + key="precipitation_type", + attribute=TMRW_ATTR_PRECIPITATION_TYPE, name="Precipitation Type", value_map=PrecipitationType, translation_key="precipitation_type", @@ -192,20 +201,23 @@ SENSOR_TYPES = ( # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Ozone is 48 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_OZONE, + key="ozone", + attribute=TMRW_ATTR_OZONE, name="Ozone", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PARTICULATE_MATTER_25, + key="particulate_matter_2_5_mm", + attribute=TMRW_ATTR_PARTICULATE_MATTER_25, name="Particulate Matter < 2.5 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PARTICULATE_MATTER_10, + key="particulate_matter_10_mm", + attribute=TMRW_ATTR_PARTICULATE_MATTER_10, name="Particulate Matter < 10 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, @@ -213,7 +225,8 @@ SENSOR_TYPES = ( # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Nitrogen Dioxide is 46.01 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_NITROGEN_DIOXIDE, + key="nitrogen_dioxide", + attribute=TMRW_ATTR_NITROGEN_DIOXIDE, name="Nitrogen Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(46.01), @@ -221,7 +234,8 @@ SENSOR_TYPES = ( ), # Data comes in as ppb, convert to ppm TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CARBON_MONOXIDE, + key="carbon_monoxide", + attribute=TMRW_ATTR_CARBON_MONOXIDE, name="Carbon Monoxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, multiplication_factor=1 / 1000, @@ -230,82 +244,95 @@ SENSOR_TYPES = ( # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Sulphur Dioxide is 64.07 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_SULPHUR_DIOXIDE, + key="sulphur_dioxide", + attribute=TMRW_ATTR_SULPHUR_DIOXIDE, name="Sulphur Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(64.07), device_class=SensorDeviceClass.SULPHUR_DIOXIDE, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_AQI, + key="us_epa_air_quality_index", + attribute=TMRW_ATTR_EPA_AQI, name="US EPA Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + key="us_epa_primary_pollutant", + attribute=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, name="US EPA Primary Pollutant", value_map=PrimaryPollutantType, translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_HEALTH_CONCERN, + key="us_epa_health_concern", + attribute=TMRW_ATTR_EPA_HEALTH_CONCERN, name="US EPA Health Concern", value_map=HealthConcernType, translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CHINA_AQI, + key="china_mep_air_quality_index", + attribute=TMRW_ATTR_CHINA_AQI, name="China MEP Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + key="china_mep_primary_pollutant", + attribute=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, name="China MEP Primary Pollutant", value_map=PrimaryPollutantType, translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CHINA_HEALTH_CONCERN, + key="china_mep_health_concern", + attribute=TMRW_ATTR_CHINA_HEALTH_CONCERN, name="China MEP Health Concern", value_map=HealthConcernType, translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_POLLEN_TREE, + key="tree_pollen_index", + attribute=TMRW_ATTR_POLLEN_TREE, name="Tree Pollen Index", icon="mdi:tree", value_map=PollenIndex, translation_key="pollen_index", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_POLLEN_WEED, + key="weed_pollen_index", + attribute=TMRW_ATTR_POLLEN_WEED, name="Weed Pollen Index", value_map=PollenIndex, translation_key="pollen_index", icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_POLLEN_GRASS, + key="grass_pollen_index", + attribute=TMRW_ATTR_POLLEN_GRASS, name="Grass Pollen Index", icon="mdi:grass", value_map=PollenIndex, translation_key="pollen_index", ), TomorrowioSensorEntityDescription( - TMRW_ATTR_FIRE_INDEX, + key="fire_index", + attribute=TMRW_ATTR_FIRE_INDEX, name="Fire Index", icon="mdi:fire", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_UV_INDEX, + key="uv_index", + attribute=TMRW_ATTR_UV_INDEX, name="UV Index", state_class=SensorStateClass.MEASUREMENT, icon="mdi:sun-wireless", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_UV_HEALTH_CONCERN, + key="uv_radiation_health_concern", + attribute=TMRW_ATTR_UV_HEALTH_CONCERN, name="UV Radiation Health Concern", value_map=UVDescription, translation_key="uv_index", @@ -356,9 +383,7 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): super().__init__(config_entry, coordinator, api_version) self.entity_description = description self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" - self._attr_unique_id = ( - f"{self._config_entry.unique_id}_{slugify(description.name)}" - ) + self._attr_unique_id = f"{self._config_entry.unique_id}_{description.key}" if self.entity_description.native_unit_of_measurement is None: self._attr_native_unit_of_measurement = description.unit_metric if hass.config.units is US_CUSTOMARY_SYSTEM: @@ -403,6 +428,6 @@ class TomorrowioSensorEntity(BaseTomorrowioSensorEntity): @property def _state(self) -> int | float | None: """Return the raw state.""" - val = self._get_current_property(self.entity_description.key) + val = self._get_current_property(self.entity_description.attribute) assert not isinstance(val, str) return val From e57ed26896a138d0bd444ba89ec2854248e01ed0 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 4 Sep 2023 13:51:33 -0400 Subject: [PATCH 136/984] Bump pyschlage to 2023.9.0 (#99624) --- 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 25316004c58..fb4ccc81dee 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.8.1"] + "requirements": ["pyschlage==2023.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a0efbb697b2..b091bfccc48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.8.1 +pyschlage==2023.9.0 # homeassistant.components.sensibo pysensibo==1.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 705d67b73b4..7923a625e5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1482,7 +1482,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.8.1 +pyschlage==2023.9.0 # homeassistant.components.sensibo pysensibo==1.0.33 From 26fd36dc4c10755f267662b7ec9d503db1f1a83c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 20:10:16 +0200 Subject: [PATCH 137/984] Revert "Deprecate timer start optional duration parameter" (#99613) Revert "Deprecate timer start optional duration parameter (#93471)" This reverts commit 2ce5b08fc36e77a2594a39040e5440d2ca01dff8. --- homeassistant/components/timer/__init__.py | 13 ------------- homeassistant/components/timer/strings.json | 13 ------------- tests/components/timer/test_init.py | 16 ++-------------- 3 files changed, 2 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 1bc8eb8fd5e..228e2071b4a 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -22,7 +22,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -304,18 +303,6 @@ class Timer(collection.CollectionEntity, RestoreEntity): @callback def async_start(self, duration: timedelta | None = None): """Start a timer.""" - if duration: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_duration_in_start", - breaks_in_ha_version="2024.3.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_duration_in_start", - ) - if self._listener: self._listener() self._listener = None diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index c85a9f4c55e..56cb46d26b4 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -63,18 +63,5 @@ } } } - }, - "issues": { - "deprecated_duration_in_start": { - "title": "The timer start service duration parameter is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::timer::issues::deprecated_duration_in_start::title%]", - "description": "The timer service `timer.start` optional duration parameter is being removed and use of it has been detected. To change the duration please create a new timer.\n\nPlease remove the use of the `duration` parameter in the `timer.start` service in your automations and scripts and select **submit** to close this issue." - } - } - } - } } } diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 7bc2df87f35..eabc5e04e0b 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -46,11 +46,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import ( - config_validation as cv, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.restore_state import StoredState, async_get from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -270,9 +266,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: @pytest.mark.freeze_time("2023-06-05 17:47:50") -async def test_start_service( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: +async def test_start_service(hass: HomeAssistant) -> None: """Test the start/stop service.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -317,12 +311,6 @@ async def test_start_service( blocking=True, ) await hass.async_block_till_done() - - # Ensure an issue is raised for the use of this deprecated service - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecated_duration_in_start" - ) - state = hass.states.get("timer.test1") assert state assert state.state == STATUS_ACTIVE From 7e36da4cc0b2bf1ee962f3b8e96eb1ef4fe6a1ea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 20:17:30 +0200 Subject: [PATCH 138/984] Small cleanup of WS command render_template (#99562) --- homeassistant/components/websocket_api/commands.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index c6564967a39..84c7567a40e 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -516,7 +516,6 @@ async def handle_render_template( template_obj = _cached_template(template_str, hass) variables = msg.get("variables") timeout = msg.get("timeout") - info = None if timeout: try: @@ -540,7 +539,6 @@ async def handle_render_template( event: EventType[EventStateChangedData] | None, updates: list[TrackTemplateResult], ) -> None: - nonlocal info track_template_result = updates.pop() result = track_template_result.result if isinstance(result, TemplateError): @@ -549,7 +547,7 @@ async def handle_render_template( connection.send_message( messages.event_message( - msg["id"], {"result": result, "listeners": info.listeners} # type: ignore[attr-defined] + msg["id"], {"result": result, "listeners": info.listeners} ) ) From e5ebba07532ed87d3695f3a094390ab8548fb996 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 13:19:10 -0500 Subject: [PATCH 139/984] Fix module check in _async_get_flow_handler (#99509) We should have been checking for the module in hass.data[DATA_COMPONENTS] and not hass.config.components as the check was ineffective if there were no existing integrations instances for the domain which is the case for discovery or when the integration is ignored --- homeassistant/config_entries.py | 4 +++- homeassistant/loader.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7900c6b62a4..f627b804989 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2081,7 +2081,9 @@ async def _async_get_flow_handler( """Get a flow handler for specified domain.""" # First check if there is a handler registered for the domain - if domain in hass.config.components and (handler := HANDLERS.get(domain)): + if loader.is_component_module_loaded(hass, f"{domain}.config_flow") and ( + handler := HANDLERS.get(domain) + ): return handler await _load_integration(hass, domain, hass_config) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 8906cefb241..9d4d6e880f8 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1187,3 +1187,8 @@ def _lookup_path(hass: HomeAssistant) -> list[str]: if hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] + + +def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: + """Test if a component module is loaded.""" + return module in hass.data[DATA_COMPONENTS] From a77f1cbd9e19c5758c8bda9536286afd26c4c10e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 4 Sep 2023 20:23:46 +0200 Subject: [PATCH 140/984] Mark AVM Fritz!Smarthome as Gold integration (#97086) set quality scale to gold --- homeassistant/components/fritzbox/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 35b78e91f81..fdf38d88439 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], + "quality_scale": "gold", "requirements": ["pyfritzhome==0.6.9"], "ssdp": [ { From c1cfded355142d358e4d8274743cfd7954553841 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Sep 2023 20:44:20 +0200 Subject: [PATCH 141/984] Update frontend to 20230904.0 (#99636) --- 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 3b46f568d3e..156adfa73d2 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==20230901.0"] + "requirements": ["home-assistant-frontend==20230904.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a1d4a0c7bf9..cf17cb9b913 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230904.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b091bfccc48..4996ca70ea0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230904.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7923a625e5f..522f891f7ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230904.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From bc1575a47783acf40cbf6ccc835d1fc2e80ec584 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 22:18:36 +0200 Subject: [PATCH 142/984] Move variables out of constructor in Nobo hub (#99617) --- homeassistant/components/nobo_hub/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index e3cfa04802c..7041d097f3e 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -74,6 +74,8 @@ class NoboZone(ClimateEntity): _attr_max_temp = MAX_TEMPERATURE _attr_min_temp = MIN_TEMPERATURE _attr_precision = PRECISION_TENTHS + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] + _attr_hvac_mode = HVACMode.AUTO _attr_preset_modes = PRESET_MODES _attr_supported_features = SUPPORT_FLAGS _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -85,8 +87,6 @@ class NoboZone(ClimateEntity): self._id = zone_id self._nobo = hub self._attr_unique_id = f"{hub.hub_serial}:{zone_id}" - self._attr_hvac_mode = HVACMode.AUTO - self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] self._override_type = override_type self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, From de73cafc8bba413c0f6d496da7148b604031da8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 22:19:40 +0200 Subject: [PATCH 143/984] Small cleanup of TemplateEnvironment (#99571) * Small cleanup of TemplateEnvironment * Fix typo --- homeassistant/helpers/template.py | 57 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 40d64ba37ae..b5a6a45e97f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -492,7 +492,7 @@ class Template: if ret is None: ret = self.hass.data[wanted_env] = TemplateEnvironment( self.hass, - self._limited, # type: ignore[no-untyped-call] + self._limited, self._strict, ) return ret @@ -2276,7 +2276,12 @@ class HassLoader(jinja2.BaseLoader): class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" - def __init__(self, hass, limited=False, strict=False): + def __init__( + self, + hass: HomeAssistant | None, + limited: bool | None = False, + strict: bool | None = False, + ) -> None: """Initialise template environment.""" undefined: type[LoggingUndefined] | type[jinja2.StrictUndefined] if not strict: @@ -2381,6 +2386,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): # can be discarded, we only need to get at the hass object. def hassfunction( func: Callable[Concatenate[HomeAssistant, _P], _R], + jinja_context: Callable[ + [Callable[Concatenate[Any, _P], _R]], + Callable[Concatenate[Any, _P], _R], + ] = pass_context, ) -> Callable[Concatenate[Any, _P], _R]: """Wrap function that depend on hass.""" @@ -2388,42 +2397,40 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: return func(hass, *args, **kwargs) - return pass_context(wrapper) + return jinja_context(wrapper) self.globals["device_entities"] = hassfunction(device_entities) - self.filters["device_entities"] = pass_context(self.globals["device_entities"]) + self.filters["device_entities"] = self.globals["device_entities"] self.globals["device_attr"] = hassfunction(device_attr) - self.filters["device_attr"] = pass_context(self.globals["device_attr"]) + self.filters["device_attr"] = self.globals["device_attr"] self.globals["is_device_attr"] = hassfunction(is_device_attr) - self.tests["is_device_attr"] = pass_eval_context(self.globals["is_device_attr"]) + self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) self.globals["config_entry_id"] = hassfunction(config_entry_id) - self.filters["config_entry_id"] = pass_context(self.globals["config_entry_id"]) + self.filters["config_entry_id"] = self.globals["config_entry_id"] self.globals["device_id"] = hassfunction(device_id) - self.filters["device_id"] = pass_context(self.globals["device_id"]) + self.filters["device_id"] = self.globals["device_id"] self.globals["areas"] = hassfunction(areas) - self.filters["areas"] = pass_context(self.globals["areas"]) + self.filters["areas"] = self.globals["areas"] self.globals["area_id"] = hassfunction(area_id) - self.filters["area_id"] = pass_context(self.globals["area_id"]) + self.filters["area_id"] = self.globals["area_id"] self.globals["area_name"] = hassfunction(area_name) - self.filters["area_name"] = pass_context(self.globals["area_name"]) + self.filters["area_name"] = self.globals["area_name"] self.globals["area_entities"] = hassfunction(area_entities) - self.filters["area_entities"] = pass_context(self.globals["area_entities"]) + self.filters["area_entities"] = self.globals["area_entities"] self.globals["area_devices"] = hassfunction(area_devices) - self.filters["area_devices"] = pass_context(self.globals["area_devices"]) + self.filters["area_devices"] = self.globals["area_devices"] self.globals["integration_entities"] = hassfunction(integration_entities) - self.filters["integration_entities"] = pass_context( - self.globals["integration_entities"] - ) + self.filters["integration_entities"] = self.globals["integration_entities"] if limited: # Only device_entities is available to limited templates, mark other @@ -2479,25 +2486,25 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return self.globals["expand"] = hassfunction(expand) - self.filters["expand"] = pass_context(self.globals["expand"]) + self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = pass_context(hassfunction(closest_filter)) + self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_eval_context( - self.globals["is_hidden_entity"] + self.tests["is_hidden_entity"] = hassfunction( + is_hidden_entity, pass_eval_context ) self.globals["is_state"] = hassfunction(is_state) - self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) + self.tests["is_state"] = hassfunction(is_state, pass_eval_context) self.globals["is_state_attr"] = hassfunction(is_state_attr) - self.tests["is_state_attr"] = pass_eval_context(self.globals["is_state_attr"]) + self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) self.globals["state_attr"] = hassfunction(state_attr) self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) self.filters["states"] = self.globals["states"] self.globals["has_value"] = hassfunction(has_value) - self.filters["has_value"] = pass_context(self.globals["has_value"]) - self.tests["has_value"] = pass_eval_context(self.globals["has_value"]) + self.filters["has_value"] = self.globals["has_value"] + self.tests["has_value"] = hassfunction(has_value, pass_eval_context) self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) self.globals["relative_time"] = hassfunction(relative_time) @@ -2575,4 +2582,4 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return cached -_NO_HASS_ENV = TemplateEnvironment(None) # type: ignore[no-untyped-call] +_NO_HASS_ENV = TemplateEnvironment(None) From b8f35fb5777cb2fcc0c67e26c39246e9937d50c4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Sep 2023 22:31:53 +0200 Subject: [PATCH 144/984] Fix missing unique id in SQL (#99641) --- homeassistant/components/sql/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index f4f44d4f9a4..3fdc6b2c079 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -123,7 +123,7 @@ async def async_setup_entry( value_template.hass = hass name_template = Template(name, hass) - trigger_entity_config = {CONF_NAME: name_template} + trigger_entity_config = {CONF_NAME: name_template, CONF_UNIQUE_ID: entry.entry_id} for key in TRIGGER_ENTITY_OPTIONS: if key not in entry.options: continue From 216a174cba645d5866c34af306000f052b76f1d4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 22:35:58 +0200 Subject: [PATCH 145/984] Move variables out of constructor in nightscout (#99612) * Move variables out of constructor in nightscout * Update homeassistant/components/nightscout/sensor.py Co-authored-by: G Johansson --------- Co-authored-by: G Johansson --- homeassistant/components/nightscout/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 795e7b17a16..f60c70cc67c 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -37,15 +37,15 @@ async def async_setup_entry( class NightscoutSensor(SensorEntity): """Implementation of a Nightscout sensor.""" + _attr_native_unit_of_measurement = "mg/dL" + _attr_icon = "mdi:cloud-question" + def __init__(self, api: NightscoutAPI, name, unique_id) -> None: """Initialize the Nightscout sensor.""" self.api = api self._attr_unique_id = unique_id self._attr_name = name self._attr_extra_state_attributes: dict[str, Any] = {} - self._attr_native_unit_of_measurement = "mg/dL" - self._attr_icon = "mdi:cloud-question" - self._attr_available = False async def async_update(self) -> None: """Fetch the latest data from Nightscout REST API and update the state.""" From 47c20495bd1be2fc5f8e23cb58d9d58b3c4cfdd2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Sep 2023 22:46:19 +0200 Subject: [PATCH 146/984] Fix not stripping no device class in template helper binary sensor (#99640) Strip none template helper binary sensor --- homeassistant/components/template/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index b2ccddedad8..ccc06989c71 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -208,6 +208,7 @@ def validate_user_input( ]: """Do post validation of user input. + For binary sensors: Strip none-sentinels. For sensors: Strip none-sentinels and validate unit of measurement. For all domaines: Set template type. """ @@ -217,8 +218,9 @@ def validate_user_input( user_input: dict[str, Any], ) -> dict[str, Any]: """Add template type to user input.""" - if template_type == Platform.SENSOR: + if template_type in (Platform.BINARY_SENSOR, Platform.SENSOR): _strip_sentinel(user_input) + if template_type == Platform.SENSOR: _validate_unit(user_input) _validate_state_class(user_input) return {"template_type": template_type} | user_input From d2a52230ff4e1863b7e867196e6f7db21b217c42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 15:51:19 -0500 Subject: [PATCH 147/984] Speed up responding to states being polled via API (#99621) * Speed up responding to states being polled via API Switch to using `as_dict_json` to avoid serializing states over and over when the states api is polled since the mobile app is already building the cache as it also polls the states via the websocket_api * Speed up responding to states being polled via API Switch to using `as_dict_json` to avoid serializing states over and over when the states api is polled since the mobile app is already building the cache as it also polls the states via the websocket_api * fix json * cover --- homeassistant/components/api/__init__.py | 29 ++++++++++++++++-------- tests/components/api/test_init.py | 4 +++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index b427341546e..10cf63b701d 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -14,6 +14,7 @@ from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ( + CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, URL_API, @@ -195,15 +196,19 @@ class APIStatesView(HomeAssistantView): user: User = request["hass_user"] hass: HomeAssistant = request.app["hass"] if user.is_admin: - return self.json([state.as_dict() for state in hass.states.async_all()]) - entity_perm = user.permissions.check_entity - return self.json( - [ - state.as_dict() + states = (state.as_dict_json() for state in hass.states.async_all()) + else: + entity_perm = user.permissions.check_entity + states = ( + state.as_dict_json() for state in hass.states.async_all() if entity_perm(state.entity_id, "read") - ] + ) + response = web.Response( + body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON ) + response.enable_compression() + return response class APIEntityStateView(HomeAssistantView): @@ -213,14 +218,18 @@ class APIEntityStateView(HomeAssistantView): name = "api:entity-state" @ha.callback - def get(self, request, entity_id): + def get(self, request: web.Request, entity_id: str) -> web.Response: """Retrieve state of entity.""" - user = request["hass_user"] + user: User = request["hass_user"] + hass: HomeAssistant = request.app["hass"] if not user.permissions.check_entity(entity_id, POLICY_READ): raise Unauthorized(entity_id=entity_id) - if state := request.app["hass"].states.get(entity_id): - return self.json(state) + if state := hass.states.get(entity_id): + return web.Response( + body=state.as_dict_json(), + content_type=CONTENT_TYPE_JSON, + ) return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) async def post(self, request, entity_id): diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index f61988eff5a..38528b335b0 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -575,11 +575,13 @@ async def test_states( ) -> None: """Test fetching all states as admin.""" hass.states.async_set("test.entity", "hello") + hass.states.async_set("test.entity2", "hello") resp = await mock_api_client.get(const.URL_API_STATES) assert resp.status == HTTPStatus.OK json = await resp.json() - assert len(json) == 1 + assert len(json) == 2 assert json[0]["entity_id"] == "test.entity" + assert json[1]["entity_id"] == "test.entity2" async def test_states_view_filters( From 0b383067ef819724296bd999688904a7af0722a7 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 4 Sep 2023 22:51:57 +0200 Subject: [PATCH 148/984] Move non legacy stt models out from legacy module (#99582) --- homeassistant/components/stt/__init__.py | 3 +- homeassistant/components/stt/legacy.py | 29 +------------------ homeassistant/components/stt/models.py | 37 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/stt/models.py diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 679f9b29e41..b1730a09357 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -40,12 +40,11 @@ from .const import ( ) from .legacy import ( Provider, - SpeechMetadata, - SpeechResult, async_default_provider, async_get_provider, async_setup_legacy, ) +from .models import SpeechMetadata, SpeechResult __all__ = [ "async_get_provider", diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index f14eed467db..862f59d5f6d 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import AsyncIterable, Coroutine -from dataclasses import dataclass import logging from typing import Any @@ -20,8 +19,8 @@ from .const import ( AudioCodecs, AudioFormats, AudioSampleRates, - SpeechResultState, ) +from .models import SpeechMetadata, SpeechResult _LOGGER = logging.getLogger(__name__) @@ -88,32 +87,6 @@ def async_setup_legacy( ] -@dataclass -class SpeechMetadata: - """Metadata of audio stream.""" - - language: str - format: AudioFormats - codec: AudioCodecs - bit_rate: AudioBitRates - sample_rate: AudioSampleRates - channel: AudioChannels - - def __post_init__(self) -> None: - """Finish initializing the metadata.""" - self.bit_rate = AudioBitRates(int(self.bit_rate)) - self.sample_rate = AudioSampleRates(int(self.sample_rate)) - self.channel = AudioChannels(int(self.channel)) - - -@dataclass -class SpeechResult: - """Result of audio Speech.""" - - text: str | None - result: SpeechResultState - - class Provider(ABC): """Represent a single STT provider.""" diff --git a/homeassistant/components/stt/models.py b/homeassistant/components/stt/models.py new file mode 100644 index 00000000000..45322e2da07 --- /dev/null +++ b/homeassistant/components/stt/models.py @@ -0,0 +1,37 @@ +"""Speech-to-text data models.""" +from dataclasses import dataclass + +from .const import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + + +@dataclass +class SpeechMetadata: + """Metadata of audio stream.""" + + language: str + format: AudioFormats + codec: AudioCodecs + bit_rate: AudioBitRates + sample_rate: AudioSampleRates + channel: AudioChannels + + def __post_init__(self) -> None: + """Finish initializing the metadata.""" + self.bit_rate = AudioBitRates(int(self.bit_rate)) + self.sample_rate = AudioSampleRates(int(self.sample_rate)) + self.channel = AudioChannels(int(self.channel)) + + +@dataclass +class SpeechResult: + """Result of audio Speech.""" + + text: str | None + result: SpeechResultState From 63273a307a04a0bb666de6ad81f49fa45c9c4c26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 19:42:05 -0500 Subject: [PATCH 149/984] Fix Bluetooth passive update processor dispatching updates to unchanged entities (#99527) * Fix passive update processor dispatching updates to unchanged entities * adjust tests * coverage * fix * Update homeassistant/components/bluetooth/update_coordinator.py --- .../bluetooth/passive_update_coordinator.py | 1 + .../bluetooth/passive_update_processor.py | 35 +++++++++++++++---- .../bluetooth/update_coordinator.py | 14 ++------ .../bluetooth/test_active_update_processor.py | 10 +++--- .../test_passive_update_processor.py | 28 +++++++++++++++ 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index 6f1749aeef2..fcf6fcdf255 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -85,6 +85,7 @@ class PassiveBluetoothDataUpdateCoordinator( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" + self._available = True self.async_update_listeners() diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 6d0621fa4f6..7294d55f912 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -341,6 +341,8 @@ class PassiveBluetoothProcessorCoordinator( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" + was_available = self._available + self._available = True if self.hass.is_stopping: return @@ -358,7 +360,7 @@ class PassiveBluetoothProcessorCoordinator( self.logger.info("Coordinator %s recovered", self.name) for processor in self._processors: - processor.async_handle_update(update) + processor.async_handle_update(update, was_available) _PassiveBluetoothDataProcessorT = TypeVar( @@ -515,20 +517,39 @@ class PassiveBluetoothDataProcessor(Generic[_T]): @callback def async_update_listeners( - self, data: PassiveBluetoothDataUpdate[_T] | None + self, + data: PassiveBluetoothDataUpdate[_T] | None, + was_available: bool | None = None, ) -> None: """Update all registered listeners.""" + if was_available is None: + was_available = self.coordinator.available + # Dispatch to listeners without a filter key for update_callback in self._listeners: update_callback(data) + if not was_available or data is None: + # When data is None, or was_available is False, + # dispatch to all listeners as it means the device + # is flipping between available and unavailable + for listeners in self._entity_key_listeners.values(): + for update_callback in listeners: + update_callback(data) + return + # Dispatch to listeners with a filter key - for listeners in self._entity_key_listeners.values(): - for update_callback in listeners: - update_callback(data) + # if the key is in the data + entity_key_listeners = self._entity_key_listeners + for entity_key in data.entity_data: + if maybe_listener := entity_key_listeners.get(entity_key): + for update_callback in maybe_listener: + update_callback(data) @callback - def async_handle_update(self, update: _T) -> None: + def async_handle_update( + self, update: _T, was_available: bool | None = None + ) -> None: """Handle a Bluetooth event.""" try: new_data = self.update_method(update) @@ -553,7 +574,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): ) self.data.update(new_data) - self.async_update_listeners(new_data) + self.async_update_listeners(new_data, was_available) class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 9c38bf2f520..12bff3be645 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -39,6 +39,8 @@ class BasePassiveBluetoothCoordinator(ABC): self.mode = mode self._last_unavailable_time = 0.0 self._last_name = address + # Subclasses are responsible for setting _available to True + # when the abstractmethod _async_handle_bluetooth_event is called. self._available = async_address_present(hass, address, connectable) @callback @@ -88,23 +90,13 @@ class BasePassiveBluetoothCoordinator(ABC): """Return if the device is available.""" return self._available - @callback - def _async_handle_bluetooth_event_internal( - self, - service_info: BluetoothServiceInfoBleak, - change: BluetoothChange, - ) -> None: - """Handle a bluetooth event.""" - self._available = True - self._async_handle_bluetooth_event(service_info, change) - @callback def _async_start(self) -> None: """Start the callbacks.""" self._on_stop.append( async_register_callback( self.hass, - self._async_handle_bluetooth_event_internal, + self._async_handle_bluetooth_event, BluetoothCallbackMatcher( address=self.address, connectable=self.connectable ), diff --git a/tests/components/bluetooth/test_active_update_processor.py b/tests/components/bluetooth/test_active_update_processor.py index 83ad809016a..fba86223a2d 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -91,7 +91,7 @@ async def test_basic_usage( # The first time, it was passed the data from parsing the advertisement # The second time, it was passed the data from polling assert len(async_handle_update.mock_calls) == 2 - assert async_handle_update.mock_calls[0] == call({"testdata": 0}) + assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False) assert async_handle_update.mock_calls[1] == call({"testdata": 1}) cancel() @@ -148,7 +148,7 @@ async def test_poll_can_be_skipped( inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, True) flag = True @@ -208,7 +208,7 @@ async def test_bleak_error_and_recover( # First poll fails inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, False) assert ( "aa:bb:cc:dd:ee:ff: Bluetooth error whilst polling: Connection was aborted" @@ -272,7 +272,7 @@ async def test_poll_failure_and_recover( # First poll fails inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, False) # Second poll works flag = False @@ -433,7 +433,7 @@ async def test_no_polling_after_stop_event( # The first time, it was passed the data from parsing the advertisement # The second time, it was passed the data from polling assert len(async_handle_update.mock_calls) == 2 - assert async_handle_update.mock_calls[0] == call({"testdata": 0}) + assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False) assert async_handle_update.mock_calls[1] == call({"testdata": 1}) hass.state = CoreState.stopping diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index c96fbfbfc99..5baff65f29a 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -858,22 +858,49 @@ async def test_integration_with_entity( mock_add_entities, ) + entity_key_events = [] + + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) + + cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener( + _async_entity_key_listener, + PassiveBluetoothEntityKey(key="humidity", device_id="primary"), + ) + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) # First call with just the remote sensor entities results in them being added assert len(mock_add_entities.mock_calls) == 1 + # should have triggered the entity key listener since the + # the device is becoming available + assert len(entity_key_events) == 1 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) # Second call with just the remote sensor entities does not add them again assert len(mock_add_entities.mock_calls) == 1 + # should not have triggered the entity key listener since there + # there is no update with the entity key + assert len(entity_key_events) == 1 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) # Third call with primary and remote sensor entities adds the primary sensor entities assert len(mock_add_entities.mock_calls) == 2 + # should not have triggered the entity key listener since there + # there is an update with the entity key + assert len(entity_key_events) == 2 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) # Forth call with both primary and remote sensor entities does not add them again assert len(mock_add_entities.mock_calls) == 2 + # should not have triggered the entity key listener since there + # there is an update with the entity key + assert len(entity_key_events) == 3 + entities = [ *mock_add_entities.mock_calls[0][1][0], *mock_add_entities.mock_calls[1][1][0], @@ -892,6 +919,7 @@ async def test_integration_with_entity( assert entity_one.entity_key == PassiveBluetoothEntityKey( key="temperature", device_id="remote" ) + cancel_async_add_entity_key_listener() cancel_coordinator() From ff2e0c570bca22aa618a1b2ec15c8ae83d6daeac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 19:53:59 -0500 Subject: [PATCH 150/984] Improve performance of google assistant supported checks (#99454) * Improve performance of google assistant supported checks * tweak * tweak * split function * tweak --- .../components/google_assistant/helpers.py | 121 ++++++++++++------ .../google_assistant/report_state.py | 23 +++- .../google_assistant/test_helpers.py | 61 ++++++--- .../google_assistant/test_report_state.py | 4 +- 4 files changed, 144 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 49d130d6656..c1b505b2bd4 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod from asyncio import gather from collections.abc import Callable, Mapping from datetime import datetime, timedelta +from functools import lru_cache from http import HTTPStatus import logging import pprint @@ -490,9 +491,34 @@ def get_google_type(domain, device_class): return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain] +@lru_cache(maxsize=4096) +def supported_traits_for_state(state: State) -> list[type[trait._Trait]]: + """Return all supported traits for state.""" + domain = state.domain + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if not isinstance(features, int): + _LOGGER.warning( + "Entity %s contains invalid supported_features value %s", + state.entity_id, + features, + ) + return [] + + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + return [ + Trait + for Trait in trait.TRAITS + if Trait.supported(domain, features, device_class, attributes) + ] + + class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" + __slots__ = ("hass", "config", "state", "_traits") + def __init__( self, hass: HomeAssistant, config: AbstractConfig, state: State ) -> None: @@ -502,6 +528,10 @@ class GoogleEntity: self.state = state self._traits: list[trait._Trait] | None = None + def __repr__(self) -> str: + """Return the representation.""" + return f"" + @property def entity_id(self): """Return entity ID.""" @@ -512,26 +542,10 @@ class GoogleEntity: """Return traits for entity.""" if self._traits is not None: return self._traits - state = self.state - domain = state.domain - attributes = state.attributes - features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) - - if not isinstance(features, int): - _LOGGER.warning( - "Entity %s contains invalid supported_features value %s", - self.entity_id, - features, - ) - return [] - - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - self._traits = [ Trait(self.hass, state, self.config) - for Trait in trait.TRAITS - if Trait.supported(domain, features, device_class, attributes) + for Trait in supported_traits_for_state(state) ] return self._traits @@ -554,18 +568,8 @@ class GoogleEntity: @callback def is_supported(self) -> bool: - """Return if the entity is supported by Google.""" - features: int | None = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) - - result = self.config.is_supported_cache.get(self.entity_id) - - if result is None or result[0] != features: - result = self.config.is_supported_cache[self.entity_id] = ( - features, - bool(self.traits()), - ) - - return result[1] + """Return if entity is supported.""" + return bool(self.traits()) @callback def might_2fa(self) -> bool: @@ -725,19 +729,64 @@ def deep_update(target, source): return target +@callback +def async_get_google_entity_if_supported_cached( + hass: HomeAssistant, config: AbstractConfig, state: State +) -> GoogleEntity | None: + """Return a GoogleEntity if entity is supported checking the cache first. + + This function will check the cache, and call async_get_google_entity_if_supported + if the entity is not in the cache, which will update the cache. + """ + entity_id = state.entity_id + is_supported_cache = config.is_supported_cache + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + if result := is_supported_cache.get(entity_id): + cached_features, supported = result + if cached_features == features: + return GoogleEntity(hass, config, state) if supported else None + # Cache miss, check if entity is supported + return async_get_google_entity_if_supported(hass, config, state) + + +@callback +def async_get_google_entity_if_supported( + hass: HomeAssistant, config: AbstractConfig, state: State +) -> GoogleEntity | None: + """Return a GoogleEntity if entity is supported. + + This function will update the cache, but it does not check the cache first. + """ + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + entity = GoogleEntity(hass, config, state) + is_supported = bool(entity.traits()) + config.is_supported_cache[state.entity_id] = (features, is_supported) + return entity if is_supported else None + + @callback def async_get_entities( hass: HomeAssistant, config: AbstractConfig ) -> list[GoogleEntity]: """Return all entities that are supported by Google.""" - entities = [] + entities: list[GoogleEntity] = [] + is_supported_cache = config.is_supported_cache for state in hass.states.async_all(): - if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + entity_id = state.entity_id + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: continue - - entity = GoogleEntity(hass, config, state) - - if entity.is_supported(): + # Check check inlined for performance to avoid + # function calls for every entity since we enumerate + # the entire state machine here + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + if result := is_supported_cache.get(entity_id): + cached_features, supported = result + if cached_features == features: + if supported: + entities.append(GoogleEntity(hass, config, state)) + continue + # Cached features don't match, fall through to check + # if the entity is supported and update the cache. + if entity := async_get_google_entity_if_supported(hass, config, state): entities.append(entity) - return entities diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 5248ce7c4da..52228bb8715 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -6,13 +6,17 @@ import logging from typing import Any from homeassistant.const import MATCH_ALL -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.helpers.event import async_call_later, async_track_state_change from homeassistant.helpers.significant_change import create_checker from .const import DOMAIN from .error import SmartHomeError -from .helpers import AbstractConfig, GoogleEntity, async_get_entities +from .helpers import ( + AbstractConfig, + async_get_entities, + async_get_google_entity_if_supported_cached, +) # Time to wait until the homegraph updates # https://github.com/actions-on-google/smart-home-nodejs/issues/196#issuecomment-439156639 @@ -54,8 +58,10 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig report_states_job = HassJob(report_states) - async def async_entity_state_listener(changed_entity, old_state, new_state): - nonlocal unsub_pending + async def async_entity_state_listener( + changed_entity: str, old_state: State | None, new_state: State | None + ) -> None: + nonlocal unsub_pending, checker if not hass.is_running: return @@ -66,9 +72,11 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig if not google_config.should_expose(new_state): return - entity = GoogleEntity(hass, google_config, new_state) - - if not entity.is_supported(): + if not ( + entity := async_get_google_entity_if_supported_cached( + hass, google_config, new_state + ) + ): return try: @@ -77,6 +85,7 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig _LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code) return + assert checker is not None if not checker.async_is_significant_change(new_state, extra_arg=entity_data): return diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 17df677110b..001e8ff0d07 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -447,32 +447,53 @@ async def test_config_local_sdk_warn_version( ) in caplog.text -def test_is_supported_cached() -> None: - """Test is_supported is cached.""" +def test_async_get_entities_cached(hass: HomeAssistant) -> None: + """Test async_get_entities is cached.""" config = MockConfig() - def entity(features: int): - return helpers.GoogleEntity( - None, - config, - State("test.entity_id", "on", {"supported_features": features}), - ) + hass.states.async_set("light.ceiling_lights", "off") + hass.states.async_set("light.bed_light", "off") + hass.states.async_set("not_supported.not_supported", "off") + + google_entities = helpers.async_get_entities(hass, config) + assert len(google_entities) == 2 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } with patch( "homeassistant.components.google_assistant.helpers.GoogleEntity.traits", - return_value=[1], - ) as mock_traits: - assert entity(1).is_supported() is True - assert len(mock_traits.mock_calls) == 1 + return_value=RuntimeError("Should not be called"), + ): + google_entities = helpers.async_get_entities(hass, config) - # Supported feature changes, so we calculate again - assert entity(2).is_supported() is True - assert len(mock_traits.mock_calls) == 2 + assert len(google_entities) == 2 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } - mock_traits.reset_mock() + hass.states.async_set("light.new", "on") + google_entities = helpers.async_get_entities(hass, config) - # Supported feature is same, so we do not calculate again - mock_traits.side_effect = ValueError + assert len(google_entities) == 3 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.new": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } - assert entity(2).is_supported() is True - assert len(mock_traits.mock_calls) == 0 + hass.states.async_set("light.new", "on", {"supported_features": 1}) + google_entities = helpers.async_get_entities(hass, config) + + assert len(google_entities) == 3 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.new": (1, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index 3fe2a749fca..d6f4043d2f7 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -69,7 +69,7 @@ async def test_report_state( # Test that if serialize returns same value, we don't send with patch( - "homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize", + "homeassistant.components.google_assistant.helpers.GoogleEntity.query_serialize", return_value={"same": "info"}, ), patch.object(BASIC_CONFIG, "async_report_state_all", AsyncMock()) as mock_report: # New state, so reported @@ -104,7 +104,7 @@ async def test_report_state( with patch.object( BASIC_CONFIG, "async_report_state_all", AsyncMock() ) as mock_report, patch( - "homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize", + "homeassistant.components.google_assistant.helpers.GoogleEntity.query_serialize", side_effect=error.SmartHomeError("mock-error", "mock-msg"), ): hass.states.async_set("light.kitchen", "off") From fed1cab847f055302914e46ee2d8d129e1e0a5fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 19:56:34 -0500 Subject: [PATCH 151/984] Fix mobile app dispatcher performance (#99647) Fix mobile app thundering heard The mobile_app would setup a dispatcher to listener for updates on every entity and reject the ones that were not for the unique id that it was intrested in. Instead we now register for a signal per unique id since we were previously generating O(entities*sensors*devices) callbacks which was causing the event loop to stall when there were a large number of mobile app users. --- homeassistant/components/mobile_app/entity.py | 13 +++++++------ homeassistant/components/mobile_app/webhook.py | 5 ++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 3a2f038a0af..120014d1d52 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -1,6 +1,8 @@ """A entity class for mobile_app.""" from __future__ import annotations +from typing import Any + from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE from homeassistant.core import callback @@ -36,7 +38,9 @@ class MobileAppEntity(RestoreEntity): """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( - self.hass, SIGNAL_SENSOR_UPDATE, self._handle_update + self.hass, + f"{SIGNAL_SENSOR_UPDATE}-{self._attr_unique_id}", + self._handle_update, ) ) @@ -96,10 +100,7 @@ class MobileAppEntity(RestoreEntity): return self._config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE @callback - def _handle_update(self, incoming_id, data): + def _handle_update(self, data: dict[str, Any]) -> None: """Handle async event updates.""" - if incoming_id != self._attr_unique_id: - return - - self._config = {**self._config, **data} + self._config.update(data) self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 62417b0873a..1a56b13ddc5 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -607,7 +607,7 @@ async def webhook_register_sensor( if changes: entity_registry.async_update_entity(existing_sensor, **changes) - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, unique_store_key, data) + async_dispatcher_send(hass, f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", data) else: data[CONF_UNIQUE_ID] = unique_store_key data[ @@ -693,8 +693,7 @@ async def webhook_update_sensor_states( sensor[CONF_WEBHOOK_ID] = config_entry.data[CONF_WEBHOOK_ID] async_dispatcher_send( hass, - SIGNAL_SENSOR_UPDATE, - unique_store_key, + f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", sensor, ) From 49bd7e62519059ef7236d0ae3a1761ceca2070ef Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 02:59:44 +0200 Subject: [PATCH 152/984] Use shorthand attributes for Picnic (#99633) --- homeassistant/components/picnic/sensor.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index d4582afa3b2..6e35c27bbfb 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -256,9 +256,15 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): self.entity_description = description self.entity_id = f"sensor.picnic_{description.key}" - self._service_unique_id = config_entry.unique_id self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, cast(str, config_entry.unique_id))}, + manufacturer="Picnic", + model=config_entry.unique_id, + name=f"Picnic: {coordinator.data[ADDRESS]}", + ) @property def native_value(self) -> StateType | datetime: @@ -269,14 +275,3 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): else {} ) return self.entity_description.value_fn(data_set) - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, cast(str, self._service_unique_id))}, - manufacturer="Picnic", - model=self._service_unique_id, - name=f"Picnic: {self.coordinator.data[ADDRESS]}", - ) From 2c3a4b349736564b13125bbf5db8523aed08f9cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Sep 2023 08:57:09 +0200 Subject: [PATCH 153/984] Bump actions/checkout from 3.6.0 to 4.0.0 (#99651) --- .github/workflows/builder.yml | 12 ++++++------ .github/workflows/ci.yaml | 28 ++++++++++++++-------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 3296f33f84c..6ac535647b8 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 with: fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -254,7 +254,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set build additional args run: | @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Install Cosign uses: sigstore/cosign-installer@v3.1.1 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bf6ba38ea91..9651b1394d8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -87,7 +87,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -220,7 +220,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -265,7 +265,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -311,7 +311,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -360,7 +360,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -454,7 +454,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -522,7 +522,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -554,7 +554,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -587,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -631,7 +631,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -713,7 +713,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -865,7 +865,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -989,7 +989,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -1084,7 +1084,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 5affa459f52..a98c4d99734 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 01823199c17..6d947f51aca 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Get information id: info @@ -84,7 +84,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -122,7 +122,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download env_file uses: actions/download-artifact@v3 From f13e7706ed2f8baad6d5d2c3cf941a6f7984ccba Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 11:54:53 +0200 Subject: [PATCH 154/984] Use shorthand attributes in Nuheat (#99618) --- homeassistant/components/nuheat/climate.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 18b34ea0bea..4daaee10ea6 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -77,6 +77,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): ) _attr_has_entity_name = True _attr_name = None + _attr_preset_modes = PRESET_MODES def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" @@ -85,6 +86,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): self._temperature_unit = temperature_unit self._schedule_mode = None self._target_temperature = None + self._attr_unique_id = thermostat.serial_number @property def temperature_unit(self) -> str: @@ -102,11 +104,6 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): return self._thermostat.fahrenheit - @property - def unique_id(self): - """Return the unique id.""" - return self._thermostat.serial_number - @property def available(self) -> bool: """Return the unique id.""" @@ -160,11 +157,6 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): """Return current preset mode.""" return SCHEDULE_MODE_TO_PRESET_MODE_MAP.get(self._schedule_mode, PRESET_RUN) - @property - def preset_modes(self): - """Return available preset modes.""" - return PRESET_MODES - def set_preset_mode(self, preset_mode: str) -> None: """Update the hold mode of the thermostat.""" self._set_schedule_mode( From c77a0a8caae8aaafb3c1bbbf2b7bc784fb488977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 5 Sep 2023 19:42:19 +0900 Subject: [PATCH 155/984] Update aioairzone to v0.6.8 (#99644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update aioairzone to v0.6.8 Signed-off-by: Álvaro Fernández Rojas * Trigger CI --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/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/manifest.json b/homeassistant/components/airzone/manifest.json index bb1e448c8eb..c0b24b2cc3e 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.7"] + "requirements": ["aioairzone==0.6.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4996ca70ea0..6d24193a015 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -189,7 +189,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.7 +aioairzone==0.6.8 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 522f891f7ba..c5d0f31a8e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.7 +aioairzone==0.6.8 # homeassistant.components.ambient_station aioambient==2023.04.0 From 582eeea08215a8e553e90c281fcfb4774788daca Mon Sep 17 00:00:00 2001 From: itpeters <59966384+itpeters@users.noreply.github.com> Date: Tue, 5 Sep 2023 05:10:14 -0600 Subject: [PATCH 156/984] Fix long press event for matter generic switch (#99645) --- homeassistant/components/matter/event.py | 2 +- tests/components/matter/test_event.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 3a1faa6dcbe..84049301296 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -65,7 +65,7 @@ class MatterEventEntity(MatterEntity, EventEntity): if feature_map & SwitchFeature.kMomentarySwitchRelease: event_types.append("short_release") if feature_map & SwitchFeature.kMomentarySwitchLongPress: - event_types.append("long_press_ongoing") + event_types.append("long_press") event_types.append("long_release") if feature_map & SwitchFeature.kMomentarySwitchMultiPress: event_types.append("multi_press_ongoing") diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 0d5891a7778..0aa9385a74c 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -48,7 +48,7 @@ async def test_generic_switch_node( assert state.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", - "long_press_ongoing", + "long_press", "long_release", "multi_press_ongoing", "multi_press_complete", @@ -111,7 +111,7 @@ async def test_generic_switch_multi_node( assert state_button_1.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", - "long_press_ongoing", + "long_press", "long_release", ] # check button 2 From d5ad01ffbe473dfbd7548c6722403c15cf76d745 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 14:57:26 +0200 Subject: [PATCH 157/984] Use shorthand attributes in Openhome (#99629) --- .../components/openhome/media_player.py | 21 ++++++++----------- homeassistant/components/openhome/update.py | 14 +++++-------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index efc6ab37f21..51d7774a2fb 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -102,26 +102,23 @@ def catch_request_errors() -> ( class OpenhomeDevice(MediaPlayerEntity): """Representation of an Openhome device.""" + _attr_supported_features = SUPPORT_OPENHOME + _attr_state = MediaPlayerState.PLAYING + _attr_available = True + def __init__(self, hass, device): """Initialise the Openhome device.""" self.hass = hass self._device = device self._attr_unique_id = device.uuid() - self._attr_supported_features = SUPPORT_OPENHOME self._source_index = {} - self._attr_state = MediaPlayerState.PLAYING - self._attr_available = True - - @property - def device_info(self): - """Return a device description for device registry.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, self._device.uuid()), + (DOMAIN, device.uuid()), }, - manufacturer=self._device.manufacturer(), - model=self._device.model_name(), - name=self._device.friendly_name(), + manufacturer=device.manufacturer(), + model=device.model_name(), + name=device.friendly_name(), ) async def async_update(self) -> None: diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index 9013e50030f..691776e4dfd 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -54,17 +54,13 @@ class OpenhomeUpdateEntity(UpdateEntity): """Initialize a Linn DS update entity.""" self._device = device self._attr_unique_id = f"{device.uuid()}-update" - - @property - def device_info(self): - """Return a device description for device registry.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, self._device.uuid()), + (DOMAIN, device.uuid()), }, - manufacturer=self._device.manufacturer(), - model=self._device.model_name(), - name=self._device.friendly_name(), + manufacturer=device.manufacturer(), + model=device.model_name(), + name=device.friendly_name(), ) async def async_update(self) -> None: From c49f086790c1b63988679468a91b84766192340a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:22:52 +0200 Subject: [PATCH 158/984] Use shorthand attributes in Kodi (#99578) --- homeassistant/components/kodi/media_player.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 9c69abc08c8..32ecbbed626 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -282,7 +282,7 @@ class KodiEntity(MediaPlayerEntity): """Initialize the Kodi entity.""" self._connection = connection self._kodi = kodi - self._unique_id = uid + self._attr_unique_id = uid self._device_id = None self._players = None self._properties = {} @@ -369,11 +369,6 @@ class KodiEntity(MediaPlayerEntity): if close: await self._connection.close() - @property - def unique_id(self): - """Return the unique id of the device.""" - return self._unique_id - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" From 447a9f4aad04feab7ba13baa0681d79c7b09f8a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:25:00 +0200 Subject: [PATCH 159/984] Use shorthand attributes in Konnected (#99580) --- .../components/konnected/binary_sensor.py | 40 ++++--------------- homeassistant/components/konnected/sensor.py | 15 +++---- homeassistant/components/konnected/switch.py | 29 +++----------- 3 files changed, 17 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index 2f21f8c15bd..d7c41337342 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -42,38 +42,12 @@ class KonnectedBinarySensor(BinarySensorEntity): def __init__(self, device_id, zone_num, data): """Initialize the Konnected binary sensor.""" self._data = data - self._device_id = device_id - self._zone_num = zone_num - self._state = self._data.get(ATTR_STATE) - self._device_class = self._data.get(CONF_TYPE) - self._unique_id = f"{device_id}-{zone_num}" - self._name = self._data.get(CONF_NAME) - - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(KONNECTED_DOMAIN, self._device_id)}, + self._attr_is_on = data.get(ATTR_STATE) + self._attr_device_class = data.get(CONF_TYPE) + self._attr_unique_id = f"{device_id}-{zone_num}" + self._attr_name = data.get(CONF_NAME) + self._attr_device_info = DeviceInfo( + identifiers={(KONNECTED_DOMAIN, device_id)}, ) async def async_added_to_hass(self) -> None: @@ -88,5 +62,5 @@ class KonnectedBinarySensor(BinarySensorEntity): @callback def async_set_state(self, state): """Update the sensor's state.""" - self._state = state + self._attr_is_on = state self.async_write_ha_state() diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index b341afa765f..3f203d5f3e8 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -111,9 +111,9 @@ class KonnectedSensor(SensorEntity): self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}" # set initial state if known at initialization - self._state = initial_state - if self._state: - self._state = round(float(self._state), 1) + self._attr_native_value = initial_state + if initial_state: + self._attr_native_value = round(float(initial_state), 1) # set entity name if given if name := self._data.get(CONF_NAME): @@ -122,11 +122,6 @@ class KonnectedSensor(SensorEntity): self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - async def async_added_to_hass(self) -> None: """Store entity_id and register state change callback.""" entity_id_key = self._addr or self.entity_description.key @@ -139,7 +134,7 @@ class KonnectedSensor(SensorEntity): def async_set_state(self, state): """Update the sensor's state.""" if self.entity_description.key == "humidity": - self._state = int(float(state)) + self._attr_native_value = int(float(state)) else: - self._state = round(float(state), 1) + self._attr_native_value = round(float(state), 1) self.async_write_ha_state() diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index ba0dc62b606..18132a913ad 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -56,27 +56,13 @@ class KonnectedSwitch(SwitchEntity): self._momentary = self._data.get(CONF_MOMENTARY) self._pause = self._data.get(CONF_PAUSE) self._repeat = self._data.get(CONF_REPEAT) - self._state = self._boolean_state(self._data.get(ATTR_STATE)) - self._name = self._data.get(CONF_NAME) - self._unique_id = ( + self._attr_is_on = self._boolean_state(self._data.get(ATTR_STATE)) + self._attr_name = self._data.get(CONF_NAME) + self._attr_unique_id = ( f"{device_id}-{self._zone_num}-{self._momentary}-" f"{self._pause}-{self._repeat}" ) - - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state + self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) @property def panel(self): @@ -84,11 +70,6 @@ class KonnectedSwitch(SwitchEntity): device_data = self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id] return device_data.get("panel") - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo(identifiers={(KONNECTED_DOMAIN, self._device_id)}) - @property def available(self) -> bool: """Return whether the panel is available.""" @@ -129,7 +110,7 @@ class KonnectedSwitch(SwitchEntity): return self._activation == STATE_HIGH def _set_state(self, state): - self._state = state + self._attr_is_on = state self.async_write_ha_state() _LOGGER.debug( "Setting status of %s actuator zone %s to %s", From 1f648feaeff90720b22fd4d7af40db7bc265f3a4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:26:23 +0200 Subject: [PATCH 160/984] Use shorthand attributes in Kostal Plenticore (#99581) --- .../components/kostal_plenticore/sensor.py | 21 +++---------------- .../components/kostal_plenticore/switch.py | 8 +------ 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 78ab609aa16..f7bad638df4 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -745,16 +745,16 @@ class PlenticoreDataSensor( super().__init__(coordinator) self.entity_description = description self.entry_id = entry_id - self.platform_name = platform_name self.module_id = description.module_id self.data_id = description.key - self._sensor_name = description.name self._formatter: Callable[[str], Any] = PlenticoreDataFormatter.get_method( description.formatter ) - self._device_info = device_info + self._attr_device_info = device_info + self._attr_unique_id = f"{entry_id}_{self.module_id}_{self.data_id}" + self._attr_name = f"{platform_name} {description.name}" @property def available(self) -> bool: @@ -778,21 +778,6 @@ class PlenticoreDataSensor( self.coordinator.stop_fetch_data(self.module_id, self.data_id) await super().async_will_remove_from_hass() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return self._device_info - - @property - def unique_id(self) -> str: - """Return the unique id of this Sensor Entity.""" - return f"{self.entry_id}_{self.module_id}_{self.data_id}" - - @property - def name(self) -> str: - """Return the name of this Sensor Entity.""" - return f"{self.platform_name} {self._sensor_name}" - @property def native_value(self) -> StateType: """Return the state of the sensor.""" diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 574368b432f..554f8db2b68 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -116,7 +116,6 @@ class PlenticoreDataSwitch( """Create a new Switch Entity for Plenticore process data.""" super().__init__(coordinator) self.entity_description = description - self.entry_id = entry_id self.platform_name = platform_name self.module_id = description.module_id self.data_id = description.key @@ -129,7 +128,7 @@ class PlenticoreDataSwitch( self.off_label = description.off_label self._attr_unique_id = f"{entry_id}_{description.module_id}_{description.key}" - self._device_info = device_info + self._attr_device_info = device_info @property def available(self) -> bool: @@ -171,11 +170,6 @@ class PlenticoreDataSwitch( ) await self.coordinator.async_request_refresh() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return self._device_info - @property def is_on(self) -> bool: """Return true if device is on.""" From 0ae12ad08f4ffc6e1560665a44846784c79645d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:27:38 +0200 Subject: [PATCH 161/984] Use shorthand attributes in Logi circle (#99592) --- .../components/logi_circle/camera.py | 27 +++++++------------ .../components/logi_circle/sensor.py | 14 ++++------ 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 5c27d2a08ae..d1ea01e864c 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -71,10 +71,17 @@ class LogiCam(Camera): """Initialize Logi Circle camera.""" super().__init__() self._camera = camera - self._id = self._camera.mac_address - self._has_battery = self._camera.supports_feature("battery_level") + self._has_battery = camera.supports_feature("battery_level") self._ffmpeg = ffmpeg self._listeners = [] + self._attr_unique_id = camera.mac_address + self._attr_device_info = DeviceInfo( + identifiers={(LOGI_CIRCLE_DOMAIN, camera.id)}, + manufacturer=DEVICE_BRAND, + model=camera.model_name, + name=camera.name, + sw_version=camera.firmware, + ) async def async_added_to_hass(self) -> None: """Connect camera methods to signals.""" @@ -117,22 +124,6 @@ class LogiCam(Camera): for detach in self._listeners: detach() - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(LOGI_CIRCLE_DOMAIN, self._camera.id)}, - manufacturer=DEVICE_BRAND, - model=self._camera.model_name, - name=self._camera.name, - sw_version=self._camera.firmware, - ) - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index 32082b794b7..d06569a19ca 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -106,16 +106,12 @@ class LogiSensor(SensorEntity): self._attr_unique_id = f"{camera.mac_address}-{description.key}" self._activity: dict[Any, Any] = {} self._tz = time_zone - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(LOGI_CIRCLE_DOMAIN, self._camera.id)}, + self._attr_device_info = DeviceInfo( + identifiers={(LOGI_CIRCLE_DOMAIN, camera.id)}, manufacturer=DEVICE_BRAND, - model=self._camera.model_name, - name=self._camera.name, - sw_version=self._camera.firmware, + model=camera.model_name, + name=camera.name, + sw_version=camera.firmware, ) @property From 58af0ab0cda751046d647b32e9166d5254405f1d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:37:00 +0200 Subject: [PATCH 162/984] Use shorthand attributes in NZBGet (#99622) --- homeassistant/components/nzbget/switch.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index e6a2b213873..5d72cae37cf 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -47,7 +47,7 @@ class NZBGetDownloadSwitch(NZBGetEntity, SwitchEntity): entry_name: str, ) -> None: """Initialize a new NZBGet switch.""" - self._unique_id = f"{entry_id}_download" + self._attr_unique_id = f"{entry_id}_download" super().__init__( coordinator=coordinator, @@ -55,11 +55,6 @@ class NZBGetDownloadSwitch(NZBGetEntity, SwitchEntity): entry_name=entry_name, ) - @property - def unique_id(self) -> str: - """Return the unique ID of the switch.""" - return self._unique_id - @property def is_on(self): """Return the state of the switch.""" From 3c8204528939d6a1f8b12867b684d591bea216c2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:40:11 +0200 Subject: [PATCH 163/984] Use shorthand attributes in Omnilogic (#99626) --- homeassistant/components/omnilogic/sensor.py | 35 ++++++-------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 5cb7605b854..be082584308 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -66,7 +66,7 @@ class OmnilogicSensor(OmniLogicEntity, SensorEntity): coordinator: OmniLogicUpdateCoordinator, kind: str, name: str, - device_class: str, + device_class: SensorDeviceClass | None, icon: str, unit: str, item_id: tuple, @@ -85,20 +85,10 @@ class OmnilogicSensor(OmniLogicEntity, SensorEntity): unit_type = coordinator.data[backyard_id].get("Unit-of-Measurement") self._unit_type = unit_type - self._device_class = device_class - self._unit = unit + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = unit self._state_key = state_key - @property - def device_class(self): - """Return the device class of the entity.""" - return self._device_class - - @property - def native_unit_of_measurement(self): - """Return the right unit of measure.""" - return self._unit - class OmniLogicTemperatureSensor(OmnilogicSensor): """Define an OmniLogic Temperature (Air/Water) Sensor.""" @@ -123,7 +113,7 @@ class OmniLogicTemperatureSensor(OmnilogicSensor): self._attrs["hayward_temperature"] = hayward_state self._attrs["hayward_unit_of_measure"] = hayward_unit_of_measure - self._unit = UnitOfTemperature.FAHRENHEIT + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT return state @@ -143,10 +133,10 @@ class OmniLogicPumpSpeedSensor(OmnilogicSensor): pump_speed = self.coordinator.data[self._item_id][self._state_key] if pump_type == "VARIABLE": - self._unit = PERCENTAGE + self._attr_native_unit_of_measurement = PERCENTAGE state = pump_speed elif pump_type == "DUAL": - self._unit = None + self._attr_native_unit_of_measurement = None if pump_speed == 0: state = "off" elif pump_speed == self.coordinator.data[self._item_id].get( @@ -171,13 +161,12 @@ class OmniLogicSaltLevelSensor(OmnilogicSensor): """Return the state for the salt level sensor.""" salt_return = self.coordinator.data[self._item_id][self._state_key] - unit_of_measurement = self._unit if self._unit_type == "Metric": salt_return = round(int(salt_return) / 1000, 2) - unit_of_measurement = f"{UnitOfMass.GRAMS}/{UnitOfVolume.LITERS}" - - self._unit = unit_of_measurement + self._attr_native_unit_of_measurement = ( + f"{UnitOfMass.GRAMS}/{UnitOfVolume.LITERS}" + ) return salt_return @@ -188,9 +177,7 @@ class OmniLogicChlorinatorSensor(OmnilogicSensor): @property def native_value(self): """Return the state for the chlorinator sensor.""" - state = self.coordinator.data[self._item_id][self._state_key] - - return state + return self.coordinator.data[self._item_id][self._state_key] class OmniLogicPHSensor(OmnilogicSensor): @@ -224,7 +211,7 @@ class OmniLogicORPSensor(OmnilogicSensor): name: str, kind: str, item_id: tuple, - device_class: str, + device_class: SensorDeviceClass | None, icon: str, unit: str, ) -> None: From c6bdc380b662dcfd7bf3910b546c77d2b1ae9ac0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:40:43 +0200 Subject: [PATCH 164/984] Use shorthand attributes in Ondilo ico (#99627) --- homeassistant/components/ondilo_ico/sensor.py | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 4345f3498fd..90c79003b8a 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -153,7 +153,13 @@ class OndiloICO( pooldata = self._pooldata() self._attr_unique_id = f"{pooldata['ICO']['serial_number']}-{description.key}" - self._device_name = pooldata["name"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, pooldata["ICO"]["serial_number"])}, + manufacturer="Ondilo", + model="ICO", + name=pooldata["name"], + sw_version=pooldata["ICO"]["sw_version"], + ) def _pooldata(self): """Get pool data dict.""" @@ -177,15 +183,3 @@ class OndiloICO( def native_value(self): """Last value of the sensor.""" return self._devdata()["value"] - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for the sensor.""" - pooldata = self._pooldata() - return DeviceInfo( - identifiers={(DOMAIN, pooldata["ICO"]["serial_number"])}, - manufacturer="Ondilo", - model="ICO", - name=self._device_name, - sw_version=pooldata["ICO"]["sw_version"], - ) From 5afba6327c10ea65c8a5db60543846f21c019f18 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Sep 2023 09:58:32 -0400 Subject: [PATCH 165/984] Bump zwave-js-server-python to 0.51.1 (#99652) * Bump zwave-js-server-python to 0.51.1 * Update test --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 73fa41a8cca..080074451bd 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.51.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 6d24193a015..e4f4ebd5885 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2801,7 +2801,7 @@ zigpy==0.57.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.0 +zwave-js-server-python==0.51.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5d0f31a8e8..7bcf9cf3f47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2065,7 +2065,7 @@ zigpy-znp==0.11.4 zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.0 +zwave-js-server-python==0.51.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index e686def8883..02ed507cabe 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3679,7 +3679,6 @@ async def test_abort_firmware_update( ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) - client.async_send_command.return_value = {} await ws_client.send_json( { ID: 1, @@ -3690,8 +3689,8 @@ async def test_abort_firmware_update( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.abort_firmware_update" assert args["nodeId"] == multisensor_6.node_id From 2c45d43e7b87d9e911df3e356dcbd1898cded692 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 16:33:46 +0200 Subject: [PATCH 166/984] Use shorthand attributes in Neato (#99605) Co-authored-by: Robert Resch --- homeassistant/components/neato/camera.py | 6 +--- homeassistant/components/neato/entity.py | 6 +--- homeassistant/components/neato/sensor.py | 38 ++++++------------------ homeassistant/components/neato/switch.py | 26 ++++------------ homeassistant/components/neato/vacuum.py | 26 +++++++--------- 5 files changed, 27 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index c1513bb1de6..9ce66a53622 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -57,6 +57,7 @@ class NeatoCleaningMap(NeatoEntity, Camera): self._mapdata = mapdata self._available = neato is not None self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial self._generated_at: str | None = None self._image_url: str | None = None self._image: bytes | None = None @@ -109,11 +110,6 @@ class NeatoCleaningMap(NeatoEntity, Camera): self._generated_at = map_data.get("generated_at") self._available = True - @property - def unique_id(self) -> str: - """Return unique ID.""" - return self._robot_serial - @property def available(self) -> bool: """Return if the robot is available.""" diff --git a/homeassistant/components/neato/entity.py b/homeassistant/components/neato/entity.py index 43072f19693..46ad358c638 100644 --- a/homeassistant/components/neato/entity.py +++ b/homeassistant/components/neato/entity.py @@ -17,11 +17,7 @@ class NeatoEntity(Entity): def __init__(self, robot: Robot) -> None: """Initialize Neato entity.""" self.robot = robot - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info: DeviceInfo = DeviceInfo( identifiers={(NEATO_DOMAIN, self.robot.serial)}, name=self.robot.name, ) diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 452f1bc3a9c..3b68ddcf3df 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -44,11 +44,16 @@ async def async_setup_entry( class NeatoSensor(NeatoEntity, SensorEntity): """Neato sensor.""" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = PERCENTAGE + _attr_available: bool = False + def __init__(self, neato: NeatoHub, robot: Robot) -> None: """Initialize Neato sensor.""" super().__init__(robot) - self._available: bool = False self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial self._state: dict[str, Any] | None = None def update(self) -> None: @@ -56,45 +61,20 @@ class NeatoSensor(NeatoEntity, SensorEntity): try: self._state = self.robot.state except NeatoRobotException as ex: - if self._available: + if self._attr_available: _LOGGER.error( "Neato sensor connection error for '%s': %s", self.entity_id, ex ) self._state = None - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True _LOGGER.debug("self._state=%s", self._state) - @property - def unique_id(self) -> str: - """Return unique ID.""" - return self._robot_serial - - @property - def device_class(self) -> SensorDeviceClass: - """Return the device class.""" - return SensorDeviceClass.BATTERY - - @property - def entity_category(self) -> EntityCategory: - """Device entity category.""" - return EntityCategory.DIAGNOSTIC - - @property - def available(self) -> bool: - """Return availability.""" - return self._available - @property def native_value(self) -> str | None: """Return the state.""" if self._state is not None: return str(self._state["details"]["charge"]) return None - - @property - def native_unit_of_measurement(self) -> str: - """Return unit of measurement.""" - return PERCENTAGE diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index a80d05eef23..ae90a8230b2 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -49,16 +49,17 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): """Neato Connected Switches.""" _attr_translation_key = "schedule" + _attr_available = False + _attr_entity_category = EntityCategory.CONFIG def __init__(self, neato: NeatoHub, robot: Robot, switch_type: str) -> None: """Initialize the Neato Connected switches.""" super().__init__(robot) self.type = switch_type - self._available = False self._state: dict[str, Any] | None = None self._schedule_state: str | None = None self._clean_state = None - self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial def update(self) -> None: """Update the states of Neato switches.""" @@ -66,15 +67,15 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): try: self._state = self.robot.state except NeatoRobotException as ex: - if self._available: # Print only once when available + if self._attr_available: # Print only once when available _LOGGER.error( "Neato switch connection error for '%s': %s", self.entity_id, ex ) self._state = None - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True _LOGGER.debug("self._state=%s", self._state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) @@ -86,16 +87,6 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): "Schedule state for '%s': %s", self.entity_id, self._schedule_state ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._robot_serial - @property def is_on(self) -> bool: """Return true if switch is on.""" @@ -103,11 +94,6 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): self.type == SWITCH_TYPE_SCHEDULE and self._schedule_state == STATE_ON ) - @property - def entity_category(self) -> EntityCategory: - """Device entity category.""" - return EntityCategory.CONFIG - def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self.type == SWITCH_TYPE_SCHEDULE: diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index ecc39e515c2..891b090d5d3 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -124,7 +124,6 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): self._robot_serial: str = self.robot.serial self._attr_unique_id: str = self.robot.serial self._status_state: str | None = None - self._clean_state: str | None = None self._state: dict[str, Any] | None = None self._clean_time_start: str | None = None self._clean_time_stop: str | None = None @@ -169,23 +168,23 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): robot_alert = None if self._state["state"] == 1: if self._state["details"]["isCharging"]: - self._clean_state = STATE_DOCKED + self._attr_state = STATE_DOCKED self._status_state = "Charging" elif ( self._state["details"]["isDocked"] and not self._state["details"]["isCharging"] ): - self._clean_state = STATE_DOCKED + self._attr_state = STATE_DOCKED self._status_state = "Docked" else: - self._clean_state = STATE_IDLE + self._attr_state = STATE_IDLE self._status_state = "Stopped" if robot_alert is not None: self._status_state = robot_alert elif self._state["state"] == 2: if robot_alert is None: - self._clean_state = STATE_CLEANING + self._attr_state = STATE_CLEANING self._status_state = ( f"{MODE.get(self._state['cleaning']['mode'])} " f"{ACTION.get(self._state['action'])}" @@ -200,10 +199,10 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): else: self._status_state = robot_alert elif self._state["state"] == 3: - self._clean_state = STATE_PAUSED + self._attr_state = STATE_PAUSED self._status_state = "Paused" elif self._state["state"] == 4: - self._clean_state = STATE_ERROR + self._attr_state = STATE_ERROR self._status_state = ERRORS.get(self._state["error"]) self._attr_battery_level = self._state["details"]["charge"] @@ -261,11 +260,6 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): self._robot_boundaries, ) - @property - def state(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._clean_state - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" @@ -299,7 +293,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): @property def device_info(self) -> DeviceInfo: """Device info for neato robot.""" - device_info = super().device_info + device_info = self._attr_device_info if self._robot_stats: device_info["manufacturer"] = self._robot_stats["battery"]["vendor"] device_info["model"] = self._robot_stats["model"] @@ -331,9 +325,9 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" try: - if self._clean_state == STATE_CLEANING: + if self._attr_state == STATE_CLEANING: self.robot.pause_cleaning() - self._clean_state = STATE_RETURNING + self._attr_state = STATE_RETURNING self.robot.send_to_base() except NeatoRobotException as ex: _LOGGER.error( @@ -383,7 +377,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): return _LOGGER.info("Start cleaning zone '%s' with robot %s", zone, self.entity_id) - self._clean_state = STATE_CLEANING + self._attr_state = STATE_CLEANING try: self.robot.start_cleaning(mode, navigation, category, boundary_id) except NeatoRobotException as ex: From 035fea3ee00e6a41fb7e11024813688d0fb8f039 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 09:40:25 -0500 Subject: [PATCH 167/984] Replace lambda with attrgetter in hassfest (#99662) --- script/hassfest/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 1c626ac3c5b..32803731ecd 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +from operator import attrgetter import pathlib import sys from time import monotonic @@ -229,7 +230,7 @@ def print_integrations_status( show_fixable_errors: bool = True, ) -> None: """Print integration status.""" - for integration in sorted(integrations, key=lambda itg: itg.domain): + for integration in sorted(integrations, key=attrgetter("domain")): extra = f" - {integration.path}" if config.specific_integrations else "" print(f"Integration {integration.domain}{extra}:") for error in integration.errors: From a04c61e77bd4adb1a6096a08f572f89bca9faf89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 09:41:35 -0500 Subject: [PATCH 168/984] Replace lambda with attrgetter in device_tracker device_trigger (#99663) --- homeassistant/components/device_tracker/device_trigger.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index a96f9affb1d..404dad0d4d1 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -1,6 +1,7 @@ """Provides device automations for Device Tracker.""" from __future__ import annotations +from operator import attrgetter from typing import Final import voluptuous as vol @@ -98,7 +99,7 @@ async def async_get_trigger_capabilities( """List trigger capabilities.""" zones = { ent.entity_id: ent.name - for ent in sorted(hass.states.async_all(DOMAIN_ZONE), key=lambda ent: ent.name) + for ent in sorted(hass.states.async_all(DOMAIN_ZONE), key=attrgetter("name")) } return { "extra_fields": vol.Schema( From abb0537928cc36f810f887c02ea800c7679af54a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 10:36:01 -0500 Subject: [PATCH 169/984] Replace lambda with itemgetter in script/gen_requirements_all.py (#99661) --- script/gen_requirements_all.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 101a57e419d..81fea80efad 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -4,6 +4,7 @@ from __future__ import annotations import difflib import importlib +from operator import itemgetter import os from pathlib import Path import pkgutil @@ -333,7 +334,7 @@ def process_requirements( def generate_requirements_list(reqs: dict[str, list[str]]) -> str: """Generate a pip file based on requirements.""" output = [] - for pkg, requirements in sorted(reqs.items(), key=lambda item: item[0]): + for pkg, requirements in sorted(reqs.items(), key=itemgetter(0)): for req in sorted(requirements): output.append(f"\n# {req}") From e9062bb1b3c55d3372bada2d0a8de8e5f158c8e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 10:36:43 -0500 Subject: [PATCH 170/984] Replace lambda with attrgetter in homekit_controller (#99666) --- homeassistant/components/homekit_controller/connection.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 3e5fd4655d6..348dd5e7ccf 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable, Iterable from datetime import datetime, timedelta import logging +from operator import attrgetter from types import MappingProxyType from typing import Any @@ -508,9 +509,7 @@ class HKDevice: # Accessories need to be created in the correct order or setting up # relationships with ATTR_VIA_DEVICE may fail. - for accessory in sorted( - self.entity_map.accessories, key=lambda accessory: accessory.aid - ): + for accessory in sorted(self.entity_map.accessories, key=attrgetter("aid")): device_info = self.device_info_for_accessory(accessory) device = device_registry.async_get_or_create( From 5ccf866e228adbe332c5d43315b301145b31b52e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 19:01:51 +0200 Subject: [PATCH 171/984] Use shorthand attributes for Plaato (#99634) Co-authored-by: Robert Resch --- homeassistant/components/plaato/entity.py | 35 ++++++++--------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index 755ff8d2ae7..b7650567c2b 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -30,7 +30,18 @@ class PlaatoEntity(entity.Entity): self._device_id = data[DEVICE][DEVICE_ID] self._device_type = data[DEVICE][DEVICE_TYPE] self._device_name = data[DEVICE][DEVICE_NAME] - self._state = 0 + self._attr_unique_id = f"{self._device_id}_{self._sensor_type}" + self._attr_name = f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title() + sw_version = None + if firmware := self._sensor_data.firmware_version: + sw_version = firmware + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Plaato", + model=self._device_type, + name=self._device_name, + sw_version=sw_version, + ) @property def _attributes(self) -> dict: @@ -46,28 +57,6 @@ class PlaatoEntity(entity.Entity): return self._coordinator.data return self._entry_data[SENSOR_DATA] - @property - def name(self): - """Return the name of the sensor.""" - return f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title() - - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device_id}_{self._sensor_type}" - - @property - def device_info(self) -> DeviceInfo: - """Get device info.""" - sw_version = self._sensor_data.firmware_version - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer="Plaato", - model=self._device_type, - name=self._device_name, - sw_version=sw_version if sw_version != "" else None, - ) - @property def extra_state_attributes(self): """Return the state attributes of the monitored installation.""" From 7fbb1c0fb64870399fe6a5dbf78d3e43200402f2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Sep 2023 20:12:40 +0200 Subject: [PATCH 172/984] Update frontend to 20230905.0 (#99677) --- 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 156adfa73d2..627b36a59b8 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==20230904.0"] + "requirements": ["home-assistant-frontend==20230905.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cf17cb9b913..c4492c90e9c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230904.0 +home-assistant-frontend==20230905.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e4f4ebd5885..997d93bee16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230904.0 +home-assistant-frontend==20230905.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bcf9cf3f47..d92313cf9f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230904.0 +home-assistant-frontend==20230905.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 7ab1913ba4f5b8b28e26981155b499c9ae3c79a4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 5 Sep 2023 14:30:28 -0400 Subject: [PATCH 173/984] Fix ZHA startup creating entities with non-unique IDs (#99679) * Make the ZHAGateway initialization restartable so entities are unique * Add a unit test --- homeassistant/components/zha/__init__.py | 4 +- homeassistant/components/zha/core/gateway.py | 12 ++--- tests/components/zha/test_init.py | 50 +++++++++++++++++++- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1c4c3e776d0..f9113ebaa90 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -134,7 +134,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b else: _LOGGER.debug("ZHA storage file does not exist or was already removed") - zha_gateway = ZHAGateway(hass, config, config_entry) + # Re-use the gateway object between ZHA reloads + if (zha_gateway := zha_data.get(DATA_ZHA_GATEWAY)) is None: + zha_gateway = ZHAGateway(hass, config, config_entry) try: await zha_gateway.async_initialize() diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3abf1274f98..353bc6904d7 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -149,6 +149,12 @@ class ZHAGateway: self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] @@ -191,12 +197,6 @@ class ZHAGateway: async def async_initialize(self) -> None: """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - app_controller_cls, app_config = self.get_application_controller_data() self.application_controller = await app_controller_cls.new( config=app_config, diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 24ee63fb3d5..63ca10bbf91 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,8 +1,10 @@ """Tests for ZHA integration init.""" +import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import TransientConnectionError from homeassistant.components.zha import async_setup_entry from homeassistant.components.zha.core.const import ( @@ -11,10 +13,13 @@ from homeassistant.components.zha.core.const import ( CONF_USB_PATH, DOMAIN, ) -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION +from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component +from .test_light import LIGHT_ON_OFF + from tests.common import MockConfigEntry DATA_RADIO_TYPE = "deconz" @@ -157,3 +162,46 @@ async def test_setup_with_v3_cleaning_uri( 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 + + +@patch( + "homeassistant.components.zha.PLATFORMS", + [Platform.LIGHT, Platform.BUTTON, Platform.SENSOR, Platform.SELECT], +) +async def test_zha_retry_unique_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + caplog, +) -> None: + """Test that ZHA retrying creates unique entity IDs.""" + + config_entry.add_to_hass(hass) + + # Ensure we have some device to try to load + app = mock_zigpy_connect.return_value + light = zigpy_device_mock(LIGHT_ON_OFF) + app.devices[light.ieee] = light + + # Re-try setup but have it fail once, so entities have two chances to be created + with patch.object( + app, + "startup", + side_effect=[TransientConnectionError(), None], + ) as mock_connect: + with patch( + "homeassistant.config_entries.async_call_later", + lambda hass, delay, action: async_call_later(hass, 0, action), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Wait for the config entry setup to retry + await asyncio.sleep(0.1) + + assert len(mock_connect.mock_calls) == 2 + + await hass.config_entries.async_unload(config_entry.entry_id) + + assert "does not generate unique IDs" not in caplog.text From b0e40d95adaba4bf5cfacf401ff7783dcd39313c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Sep 2023 21:13:28 +0200 Subject: [PATCH 174/984] Bump aiounifi to v61 (#99686) * Bump aiounifi to v61 * Alter a test to cover the upstream change --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_sensor.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index cb1c8f1c0dc..f20e5f9e4ac 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==60"], + "requirements": ["aiounifi==61"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 997d93bee16..ee6c41d839a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -364,7 +364,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==60 +aiounifi==61 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d92313cf9f1..e89813d102e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -339,7 +339,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==60 +aiounifi==61 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index da2c0b46f76..7ed87512f2b 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -336,8 +336,8 @@ async def test_bandwidth_sensors( "mac": "00:00:00:00:00:02", "name": "Wireless client", "oui": "Producer", - "rx_bytes-r": 2345000000, - "tx_bytes-r": 6789000000, + "rx_bytes-r": 2345000000.0, + "tx_bytes-r": 6789000000.0, } options = { CONF_ALLOW_BANDWIDTH_SENSORS: True, From a9c41f76e3fbd6085857750928a3eb2de531bcad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 5 Sep 2023 21:14:39 +0200 Subject: [PATCH 175/984] Bump millheater to 0.11.2 (#99683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Mill lib Signed-off-by: Daniel Hjelseth Høyer --- 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 39b91570190..a1538bed5cf 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.1", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.2", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee6c41d839a..915d3eba8f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1215,7 +1215,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.1 +millheater==0.11.2 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e89813d102e..2afa9594b64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -932,7 +932,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.1 +millheater==0.11.2 # homeassistant.components.minio minio==7.1.12 From 2cf25ee9ec9ea997b1e9d01204b3a5a824997238 Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Tue, 5 Sep 2023 21:18:06 +0200 Subject: [PATCH 176/984] Bump zamg to 0.3.0 (#99685) --- 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 3ff7612d47e..df17672231e 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.2.4"] + "requirements": ["zamg==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 915d3eba8f6..485d44b9df6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2762,7 +2762,7 @@ youtubeaio==1.1.5 yt-dlp==2023.7.6 # homeassistant.components.zamg -zamg==0.2.4 +zamg==0.3.0 # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2afa9594b64..ffa89229563 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2038,7 +2038,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.zamg -zamg==0.2.4 +zamg==0.3.0 # homeassistant.components.zeroconf zeroconf==0.97.0 From 3f3d8b1e1e0ebb319b70b1aa01ebeab3bf921be6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 5 Sep 2023 21:21:27 +0200 Subject: [PATCH 177/984] Bump reolink_aio to 0.7.9 (#99680) --- 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 3ff25d1e7a0..060490c6e56 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.8"] + "requirements": ["reolink-aio==0.7.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 485d44b9df6..98e597ae6e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2296,7 +2296,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.8 +reolink-aio==0.7.9 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffa89229563..a2999d0fd07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1689,7 +1689,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.8 +reolink-aio==0.7.9 # homeassistant.components.rflink rflink==0.0.65 From c64d173fcb853511f4ddeec0da760f9aec8214d8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 5 Sep 2023 21:50:51 +0200 Subject: [PATCH 178/984] Remove imap_email_content integration (#99484) --- .coveragerc | 1 - homeassistant/components/imap/config_flow.py | 30 +- .../components/imap_email_content/__init__.py | 17 - .../components/imap_email_content/const.py | 13 - .../imap_email_content/manifest.json | 8 - .../components/imap_email_content/repairs.py | 173 ---------- .../components/imap_email_content/sensor.py | 302 ------------------ .../imap_email_content/strings.json | 27 -- homeassistant/generated/integrations.json | 6 - tests/components/imap/test_config_flow.py | 67 ---- .../components/imap_email_content/__init__.py | 1 - .../imap_email_content/test_repairs.py | 296 ----------------- .../imap_email_content/test_sensor.py | 253 --------------- 13 files changed, 1 insertion(+), 1193 deletions(-) delete mode 100644 homeassistant/components/imap_email_content/__init__.py delete mode 100644 homeassistant/components/imap_email_content/const.py delete mode 100644 homeassistant/components/imap_email_content/manifest.json delete mode 100644 homeassistant/components/imap_email_content/repairs.py delete mode 100644 homeassistant/components/imap_email_content/sensor.py delete mode 100644 homeassistant/components/imap_email_content/strings.json delete mode 100644 tests/components/imap_email_content/__init__.py delete mode 100644 tests/components/imap_email_content/test_repairs.py delete mode 100644 tests/components/imap_email_content/test_sensor.py diff --git a/.coveragerc b/.coveragerc index d28878d8861..f2231ea31c2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -547,7 +547,6 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py homeassistant/components/insteon/climate.py diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 4c4a2e2a35c..70594d5fd7c 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,13 +10,7 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv @@ -132,28 +126,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: config_entries.ConfigEntry | None - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle the import from imap_email_content integration.""" - data = CONFIG_SCHEMA( - { - CONF_SERVER: user_input[CONF_SERVER], - CONF_PORT: user_input[CONF_PORT], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_FOLDER: user_input[CONF_FOLDER], - } - ) - self._async_abort_entries_match( - { - key: data[key] - for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) - } - ) - title = user_input[CONF_NAME] - if await validate_input(self.hass, data): - raise AbortFlow("cannot_connect") - return self.async_create_entry(title=title, data=data) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py deleted file mode 100644 index f2041b947df..00000000000 --- a/homeassistant/components/imap_email_content/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""The imap_email_content component.""" - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -PLATFORMS = [Platform.SENSOR] - -CONFIG_SCHEMA = cv.deprecated(DOMAIN, raise_if_present=False) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up imap_email_content.""" - return True diff --git a/homeassistant/components/imap_email_content/const.py b/homeassistant/components/imap_email_content/const.py deleted file mode 100644 index 5f1c653030e..00000000000 --- a/homeassistant/components/imap_email_content/const.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Constants for the imap email content integration.""" - -DOMAIN = "imap_email_content" - -CONF_SERVER = "server" -CONF_SENDERS = "senders" -CONF_FOLDER = "folder" - -ATTR_FROM = "from" -ATTR_BODY = "body" -ATTR_SUBJECT = "subject" - -DEFAULT_PORT = 993 diff --git a/homeassistant/components/imap_email_content/manifest.json b/homeassistant/components/imap_email_content/manifest.json deleted file mode 100644 index b7d0589b83f..00000000000 --- a/homeassistant/components/imap_email_content/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "imap_email_content", - "name": "IMAP Email Content", - "codeowners": [], - "dependencies": ["imap"], - "documentation": "https://www.home-assistant.io/integrations/imap_email_content", - "iot_class": "cloud_push" -} diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py deleted file mode 100644 index f19b0499040..00000000000 --- a/homeassistant/components/imap_email_content/repairs.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Repair flow for imap email content integration.""" - -from typing import Any - -import voluptuous as vol -import yaml - -from homeassistant import data_entry_flow -from homeassistant.components.imap import DOMAIN as IMAP_DOMAIN -from homeassistant.components.repairs import RepairsFlow -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VALUE_TEMPLATE, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.typing import ConfigType - -from .const import CONF_FOLDER, CONF_SENDERS, CONF_SERVER, DOMAIN - - -async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: - """Register an issue and suggest new config.""" - - name: str = config.get(CONF_NAME) or config[CONF_USERNAME] - - issue_id = ( - f"{name}_{config[CONF_USERNAME]}_{config[CONF_SERVER]}_{config[CONF_FOLDER]}" - ) - - if CONF_VALUE_TEMPLATE in config: - template: str = config[CONF_VALUE_TEMPLATE].template - template = template.replace("subject", 'trigger.event.data["subject"]') - template = template.replace("from", 'trigger.event.data["sender"]') - template = template.replace("date", 'trigger.event.data["date"]') - template = template.replace("body", 'trigger.event.data["text"]') - else: - template = '{{ trigger.event.data["subject"] }}' - - template_sensor_config: ConfigType = { - "template": [ - { - "trigger": [ - { - "id": "custom_event", - "platform": "event", - "event_type": "imap_content", - "event_data": {"sender": config[CONF_SENDERS][0]}, - } - ], - "sensor": [ - { - "state": template, - "name": name, - } - ], - } - ] - } - - data = { - CONF_SERVER: config[CONF_SERVER], - CONF_PORT: config[CONF_PORT], - CONF_USERNAME: config[CONF_USERNAME], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_FOLDER: config[CONF_FOLDER], - } - data[CONF_VALUE_TEMPLATE] = template - data[CONF_NAME] = name - placeholders = {"yaml_example": yaml.dump(template_sensor_config)} - placeholders.update(data) - - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2023.10.0", - is_fixable=True, - severity=ir.IssueSeverity.WARNING, - translation_key="migration", - translation_placeholders=placeholders, - data=data, - ) - - -class DeprecationRepairFlow(RepairsFlow): - """Handler for an issue fixing flow.""" - - def __init__(self, issue_id: str, config: ConfigType) -> None: - """Create flow.""" - self._name: str = config[CONF_NAME] - self._config: dict[str, Any] = config - self._issue_id = issue_id - super().__init__() - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the first step of a fix flow.""" - return await self.async_step_start() - - @callback - def _async_get_placeholders(self) -> dict[str, str] | None: - issue_registry = ir.async_get(self.hass) - description_placeholders = None - if issue := issue_registry.async_get_issue(self.handler, self.issue_id): - description_placeholders = issue.translation_placeholders - - return description_placeholders - - async def async_step_start( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Wait for the user to start the config migration.""" - placeholders = self._async_get_placeholders() - if user_input is None: - return self.async_show_form( - step_id="start", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - return await self.async_step_confirm() - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the confirm step of a fix flow.""" - placeholders = self._async_get_placeholders() - if user_input is not None: - user_input[CONF_NAME] = self._name - result = await self.hass.config_entries.flow.async_init( - IMAP_DOMAIN, context={"source": SOURCE_IMPORT}, data=self._config - ) - if result["type"] == FlowResultType.ABORT: - ir.async_delete_issue(self.hass, DOMAIN, self._issue_id) - ir.async_create_issue( - self.hass, - DOMAIN, - self._issue_id, - breaks_in_ha_version="2023.10.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecation", - translation_placeholders=placeholders, - data=self._config, - learn_more_url="https://www.home-assistant.io/integrations/imap/#using-events", - ) - return self.async_abort(reason=result["reason"]) - return self.async_create_entry( - title="", - data={}, - ) - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str | int | float | None], -) -> RepairsFlow: - """Create flow.""" - return DeprecationRepairFlow(issue_id, data) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py deleted file mode 100644 index 1df207e2968..00000000000 --- a/homeassistant/components/imap_email_content/sensor.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Email sensor support.""" -from __future__ import annotations - -from collections import deque -import datetime -import email -import imaplib -import logging - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_DATE, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - CONTENT_TYPE_TEXT_PLAIN, -) -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.ssl import client_context - -from .const import ( - ATTR_BODY, - ATTR_FROM, - ATTR_SUBJECT, - CONF_FOLDER, - CONF_SENDERS, - CONF_SERVER, - DEFAULT_PORT, -) -from .repairs import async_process_issue - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_SERVER): cv.string, - vol.Required(CONF_SENDERS): [cv.string], - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Email sensor platform.""" - reader = EmailReader( - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_SERVER], - config[CONF_PORT], - config[CONF_FOLDER], - config[CONF_VERIFY_SSL], - ) - - if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None: - value_template.hass = hass - sensor = EmailContentSensor( - hass, - reader, - config.get(CONF_NAME) or config[CONF_USERNAME], - config[CONF_SENDERS], - value_template, - ) - - hass.add_job(async_process_issue, hass, config) - - if sensor.connected: - add_entities([sensor], True) - - -class EmailReader: - """A class to read emails from an IMAP server.""" - - def __init__(self, user, password, server, port, folder, verify_ssl): - """Initialize the Email Reader.""" - self._user = user - self._password = password - self._server = server - self._port = port - self._folder = folder - self._verify_ssl = verify_ssl - self._last_id = None - self._last_message = None - self._unread_ids = deque([]) - self.connection = None - - @property - def last_id(self) -> int | None: - """Return last email uid that was processed.""" - return self._last_id - - @property - def last_unread_id(self) -> int | None: - """Return last email uid received.""" - # We assume the last id in the list is the last unread id - # We cannot know if that is the newest one, because it could arrive later - # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids - if self._unread_ids: - return int(self._unread_ids[-1]) - return self._last_id - - def connect(self): - """Login and setup the connection.""" - ssl_context = client_context() if self._verify_ssl else None - try: - self.connection = imaplib.IMAP4_SSL( - self._server, self._port, ssl_context=ssl_context - ) - self.connection.login(self._user, self._password) - return True - except imaplib.IMAP4.error: - _LOGGER.error("Failed to login to %s", self._server) - return False - - def _fetch_message(self, message_uid): - """Get an email message from a message id.""" - _, message_data = self.connection.uid("fetch", message_uid, "(RFC822)") - - if message_data is None: - return None - if message_data[0] is None: - return None - raw_email = message_data[0][1] - email_message = email.message_from_bytes(raw_email) - return email_message - - def read_next(self): - """Read the next email from the email server.""" - try: - self.connection.select(self._folder, readonly=True) - - if self._last_id is None: - # search for today and yesterday - time_from = datetime.datetime.now() - datetime.timedelta(days=1) - search = f"SINCE {time_from:%d-%b-%Y}" - else: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) - self._unread_ids = deque(data[0].split()) - while self._unread_ids: - message_uid = self._unread_ids.popleft() - if self._last_id is None or int(message_uid) > self._last_id: - self._last_id = int(message_uid) - self._last_message = self._fetch_message(message_uid) - return self._last_message - - except imaplib.IMAP4.error: - _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) - try: - self.connect() - _LOGGER.info( - "Reconnect to %s succeeded, trying last message", self._server - ) - if self._last_id is not None: - return self._fetch_message(str(self._last_id)) - except imaplib.IMAP4.error: - _LOGGER.error("Failed to reconnect") - - return None - - -class EmailContentSensor(SensorEntity): - """Representation of an EMail sensor.""" - - def __init__(self, hass, email_reader, name, allowed_senders, value_template): - """Initialize the sensor.""" - self.hass = hass - self._email_reader = email_reader - self._name = name - self._allowed_senders = [sender.upper() for sender in allowed_senders] - self._value_template = value_template - self._last_id = None - self._message = None - self._state_attributes = None - self.connected = self._email_reader.connect() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the current email state.""" - return self._message - - @property - def extra_state_attributes(self): - """Return other state attributes for the message.""" - return self._state_attributes - - def render_template(self, email_message): - """Render the message template.""" - variables = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } - return self._value_template.render(variables, parse_result=False) - - def sender_allowed(self, email_message): - """Check if the sender is in the allowed senders list.""" - return EmailContentSensor.get_msg_sender(email_message).upper() in ( - sender for sender in self._allowed_senders - ) - - @staticmethod - def get_msg_sender(email_message): - """Get the parsed message sender from the email.""" - return str(email.utils.parseaddr(email_message["From"])[1]) - - @staticmethod - def get_msg_subject(email_message): - """Decode the message subject.""" - decoded_header = email.header.decode_header(email_message["Subject"]) - header = email.header.make_header(decoded_header) - return str(header) - - @staticmethod - def get_msg_text(email_message): - """Get the message text from the email. - - Will look for text/plain or use text/html if not found. - """ - message_text = None - message_html = None - message_untyped_text = None - - for part in email_message.walk(): - if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: - if message_text is None: - message_text = part.get_payload() - elif part.get_content_type() == "text/html": - if message_html is None: - message_html = part.get_payload() - elif ( - part.get_content_type().startswith("text") - and message_untyped_text is None - ): - message_untyped_text = part.get_payload() - - if message_text is not None: - return message_text - - if message_html is not None: - return message_html - - if message_untyped_text is not None: - return message_untyped_text - - return email_message.get_payload() - - def update(self) -> None: - """Read emails and publish state change.""" - email_message = self._email_reader.read_next() - while ( - self._last_id is None or self._last_id != self._email_reader.last_unread_id - ): - if email_message is None: - self._message = None - self._state_attributes = {} - return - - self._last_id = self._email_reader.last_id - - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) - - if self._value_template is not None: - message = self.render_template(email_message) - - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } - - if self._last_id == self._email_reader.last_unread_id: - break - email_message = self._email_reader.read_next() diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json deleted file mode 100644 index b7b987b1212..00000000000 --- a/homeassistant/components/imap_email_content/strings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "issues": { - "deprecation": { - "title": "The IMAP email content integration is deprecated", - "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap). To set up a sensor for the IMAP email content, set up a template sensor with the config:\n\n```yaml\n{yaml_example}```\n\nPlease remove the deprecated `imap_email_plaform` sensor configuration from your `configuration.yaml`.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\nYou can skip this part if you have already set up a template sensor." - }, - "migration": { - "title": "The IMAP email content integration needs attention", - "fix_flow": { - "step": { - "start": { - "title": "Migrate your IMAP email configuration", - "description": "The IMAP email content integration is deprecated. Your IMAP server configuration can be migrated automatically to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap), this will enable using a custom `imap` event trigger. To set up a sensor that has an IMAP content state, a template sensor can be used. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml` after migration.\n\nSubmit to start migration of your IMAP server configuration to the `imap` integration." - }, - "confirm": { - "title": "Your IMAP server settings will be migrated", - "description": "In this step an `imap` config entry will be set up with the following configuration:\n\n```text\nServer\t{server}\nPort\t{port}\nUsername\t{username}\nPassword\t*****\nFolder\t{folder}\n```\n\nSee also: (https://www.home-assistant.io/integrations/imap/)\n\nFitering configuration on allowed `sender` is part of the template sensor config that can copied and placed in your `configuration.yaml.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\n```yaml\n{yaml_example}```\nDo not forget to cleanup the your `configuration.yaml` after migration.\n\nSubmit to migrate your IMAP server configuration to an `imap` configuration entry." - } - }, - "abort": { - "already_configured": "The IMAP server config was already migrated to the imap integration. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml`.", - "cannot_connect": "Migration failed. Failed to connect to the IMAP server. Perform a manual migration." - } - } - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a9e19441693..acf12b4f05d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2581,12 +2581,6 @@ "config_flow": true, "iot_class": "cloud_push" }, - "imap_email_content": { - "name": "IMAP Email Content", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" - }, "incomfort": { "name": "Intergas InComfort/Intouch Lan2RF gateway", "integration_type": "hub", diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index efb505cda77..d36cffbce06 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -469,73 +469,6 @@ async def test_advanced_options_form( assert assert_result == data_entry_flow.FlowResultType.FORM -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - mock_client.return_value.search.return_value = ( - "OK", - [b""], - ) - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": "IMAP", - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "folder": "INBOX", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "IMAP" - assert result2["data"] == { - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "charset": "utf-8", - "folder": "INBOX", - "search": "UnSeen UnDeleted", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_flow_connection_error(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.imap.config_flow.connect_to_server", - side_effect=AioImapException("Unexpected error"), - ), patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": "IMAP", - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "folder": "INBOX", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - @pytest.mark.parametrize("cipher_list", ["python_default", "modern", "intermediate"]) @pytest.mark.parametrize("verify_ssl", [False, True]) async def test_config_flow_with_cipherlist_and_ssl_verify( diff --git a/tests/components/imap_email_content/__init__.py b/tests/components/imap_email_content/__init__.py deleted file mode 100644 index 2c7e5692366..00000000000 --- a/tests/components/imap_email_content/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the imap_email_content component.""" diff --git a/tests/components/imap_email_content/test_repairs.py b/tests/components/imap_email_content/test_repairs.py deleted file mode 100644 index 6323dcde377..00000000000 --- a/tests/components/imap_email_content/test_repairs.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Test repairs for imap_email_content.""" - -from collections.abc import Generator -from http import HTTPStatus -from unittest.mock import MagicMock, patch - -import pytest - -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator, WebSocketGenerator - - -@pytest.fixture -def mock_client() -> Generator[MagicMock, None, None]: - """Mock the imap client.""" - with patch( - "homeassistant.components.imap_email_content.sensor.EmailReader.read_next", - return_value=None, - ), patch("imaplib.IMAP4_SSL") as mock_imap_client: - yield mock_imap_client - - -CONFIG = { - "platform": "imap_email_content", - "name": "Notifications", - "server": "imap.example.com", - "port": 993, - "username": "john.doe@example.com", - "password": "**SECRET**", - "folder": "INBOX.Notifications", - "value_template": "{{ body }}", - "senders": ["company@example.com"], -} -DESCRIPTION_PLACEHOLDERS = { - "yaml_example": "" - "template:\n" - "- sensor:\n" - " - name: Notifications\n" - " state: '{{ trigger.event.data[\"text\"] }}'\n" - " trigger:\n - event_data:\n" - " sender: company@example.com\n" - " event_type: imap_content\n" - " id: custom_event\n" - " platform: event\n", - "server": "imap.example.com", - "port": 993, - "username": "john.doe@example.com", - "password": "**SECRET**", - "folder": "INBOX.Notifications", - "value_template": '{{ trigger.event.data["text"] }}', - "name": "Notifications", -} - -CONFIG_DEFAULT = { - "platform": "imap_email_content", - "name": "Notifications", - "server": "imap.example.com", - "port": 993, - "username": "john.doe@example.com", - "password": "**SECRET**", - "folder": "INBOX.Notifications", - "senders": ["company@example.com"], -} -DESCRIPTION_PLACEHOLDERS_DEFAULT = { - "yaml_example": "" - "template:\n" - "- sensor:\n" - " - name: Notifications\n" - " state: '{{ trigger.event.data[\"subject\"] }}'\n" - " trigger:\n - event_data:\n" - " sender: company@example.com\n" - " event_type: imap_content\n" - " id: custom_event\n" - " platform: event\n", - "server": "imap.example.com", - "port": 993, - "username": "john.doe@example.com", - "password": "**SECRET**", - "folder": "INBOX.Notifications", - "value_template": '{{ trigger.event.data["subject"] }}', - "name": "Notifications", -} - - -@pytest.mark.parametrize( - ("config", "description_placeholders"), - [ - (CONFIG, DESCRIPTION_PLACEHOLDERS), - (CONFIG_DEFAULT, DESCRIPTION_PLACEHOLDERS_DEFAULT), - ], - ids=["with_value_template", "default_subject"], -) -async def test_deprecation_repair_flow( - hass: HomeAssistant, - mock_client: MagicMock, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, - config: str | None, - description_placeholders: str, -) -> None: - """Test the deprecation repair flow.""" - # setup config - await async_setup_component(hass, "sensor", {"sensor": config}) - await hass.async_block_till_done() - - state = hass.states.get("sensor.notifications") - assert state is not None - - ws_client = await hass_ws_client(hass) - client = await hass_client() - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - - msg = await ws_client.receive_json() - - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["domain"] == "imap_email_content": - issue = i - assert issue is not None - assert ( - issue["issue_id"] - == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" - ) - assert issue["is_fixable"] - url = RepairsFlowIndexView.url - resp = await client.post( - url, json={"handler": "imap_email_content", "issue_id": issue["issue_id"]} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data["description_placeholders"] == description_placeholders - assert data["step_id"] == "start" - - # Apply fix - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data["description_placeholders"] == description_placeholders - assert data["step_id"] == "confirm" - - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - - with patch( - "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ): - mock_client.return_value.search.return_value = ( - "OK", - [b""], - ) - resp = await client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data["type"] == "create_entry" - - # Assert the issue is resolved - await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 0 - - -@pytest.mark.parametrize( - ("config", "description_placeholders"), - [ - (CONFIG, DESCRIPTION_PLACEHOLDERS), - (CONFIG_DEFAULT, DESCRIPTION_PLACEHOLDERS_DEFAULT), - ], - ids=["with_value_template", "default_subject"], -) -async def test_repair_flow_where_entry_already_exists( - hass: HomeAssistant, - mock_client: MagicMock, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, - config: str | None, - description_placeholders: str, -) -> None: - """Test the deprecation repair flow and an entry already exists.""" - - await async_setup_component(hass, "sensor", {"sensor": config}) - await hass.async_block_till_done() - state = hass.states.get("sensor.notifications") - assert state is not None - - existing_imap_entry_config = { - "username": "john.doe@example.com", - "password": "password", - "server": "imap.example.com", - "port": 993, - "charset": "utf-8", - "folder": "INBOX.Notifications", - "search": "UnSeen UnDeleted", - } - - with patch("homeassistant.components.imap.async_setup_entry", return_value=True): - imap_entry = MockConfigEntry(domain="imap", data=existing_imap_entry_config) - imap_entry.add_to_hass(hass) - await hass.config_entries.async_setup(imap_entry.entry_id) - ws_client = await hass_ws_client(hass) - client = await hass_client() - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - - msg = await ws_client.receive_json() - - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["domain"] == "imap_email_content": - issue = i - assert issue is not None - assert ( - issue["issue_id"] - == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" - ) - assert issue["is_fixable"] - assert issue["translation_key"] == "migration" - - url = RepairsFlowIndexView.url - resp = await client.post( - url, json={"handler": "imap_email_content", "issue_id": issue["issue_id"]} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data["description_placeholders"] == description_placeholders - assert data["step_id"] == "start" - - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data["description_placeholders"] == description_placeholders - assert data["step_id"] == "confirm" - - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - - with patch( - "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ): - mock_client.return_value.search.return_value = ( - "OK", - [b""], - ) - resp = await client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data["type"] == "abort" - assert data["reason"] == "already_configured" - - # We should now have a non_fixable issue left since there is still - # a config in configuration.yaml - await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["domain"] == "imap_email_content": - issue = i - assert issue is not None - assert ( - issue["issue_id"] - == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" - ) - assert not issue["is_fixable"] - assert issue["translation_key"] == "deprecation" diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py deleted file mode 100644 index 3e8a6c1e282..00000000000 --- a/tests/components/imap_email_content/test_sensor.py +++ /dev/null @@ -1,253 +0,0 @@ -"""The tests for the IMAP email content sensor platform.""" -from collections import deque -import datetime -import email -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText - -from homeassistant.components.imap_email_content import sensor as imap_email_content -from homeassistant.core import HomeAssistant -from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers.template import Template -from homeassistant.setup import async_setup_component - - -class FakeEMailReader: - """A test class for sending test emails.""" - - def __init__(self, messages) -> None: - """Set up the fake email reader.""" - self._messages = messages - self.last_id = 0 - self.last_unread_id = len(messages) - - def add_test_message(self, message): - """Add a new message.""" - self.last_unread_id += 1 - self._messages.append(message) - - def connect(self): - """Stay always Connected.""" - return True - - def read_next(self): - """Get the next email.""" - if len(self._messages) == 0: - return None - self.last_id += 1 - return self._messages.popleft() - - -async def test_integration_setup_(hass: HomeAssistant) -> None: - """Test the integration component setup is successful.""" - assert await async_setup_component(hass, "imap_email_content", {}) - - -async def test_allowed_sender(hass: HomeAssistant) -> None: - """Test emails from allowed sender.""" - test_message = email.message.Message() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Test" - assert sensor.extra_state_attributes["body"] == "Test Message" - assert sensor.extra_state_attributes["from"] == "sender@test.com" - assert sensor.extra_state_attributes["subject"] == "Test" - assert ( - datetime.datetime(2016, 1, 1, 12, 44, 57) - == sensor.extra_state_attributes["date"] - ) - - -async def test_multi_part_with_text(hass: HomeAssistant) -> None: - """Test multi part emails.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - text = "Test Message" - html = "Test Message" - - textPart = MIMEText(text, "plain") - htmlPart = MIMEText(html, "html") - - msg.attach(textPart) - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert sensor.extra_state_attributes["body"] == "Test Message" - - -async def test_multi_part_only_html(hass: HomeAssistant) -> None: - """Test multi part emails with only HTML.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - html = "Test Message" - - htmlPart = MIMEText(html, "html") - - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert ( - sensor.extra_state_attributes["body"] - == "Test Message" - ) - - -async def test_multi_part_only_other_text(hass: HomeAssistant) -> None: - """Test multi part emails with only other text.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - other = "Test Message" - - htmlPart = MIMEText(other, "other") - - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert sensor.extra_state_attributes["body"] == "Test Message" - - -async def test_multiple_emails(hass: HomeAssistant) -> None: - """Test multiple emails, discarding stale states.""" - states = [] - - test_message1 = email.message.Message() - test_message1["From"] = "sender@test.com" - test_message1["Subject"] = "Test" - test_message1["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message1.set_payload("Test Message") - - test_message2 = email.message.Message() - test_message2["From"] = "sender@test.com" - test_message2["Subject"] = "Test 2" - test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58) - test_message2.set_payload("Test Message 2") - - test_message3 = email.message.Message() - test_message3["From"] = "sender@test.com" - test_message3["Subject"] = "Test 3" - test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1) - test_message3.set_payload("Test Message 2") - - def state_changed_listener(entity_id, from_s, to_s): - states.append(to_s) - - async_track_state_change(hass, ["sensor.emailtest"], state_changed_listener) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message1, test_message2])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - # Fake a new received message - sensor._email_reader.add_test_message(test_message3) - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - - assert states[0].state == "Test 2" - assert states[1].state == "Test 3" - - assert sensor.extra_state_attributes["body"] == "Test Message 2" - - -async def test_sender_not_allowed(hass: HomeAssistant) -> None: - """Test not whitelisted emails.""" - test_message = email.message.Message() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["other@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state is None - - -async def test_template(hass: HomeAssistant) -> None: - """Test value template.""" - test_message = email.message.Message() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["sender@test.com"], - Template("{{ subject }} from {{ from }} with message {{ body }}", hass), - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Test from sender@test.com with message Test Message" From a1359c1ce32d8de5c3fe5ef99fffb52736a2a74b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 15:44:59 -0500 Subject: [PATCH 179/984] Replace lambda in script/gen_requirements_all.py with str.lower (#99665) --- script/gen_requirements_all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 81fea80efad..7d587d761ec 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -426,7 +426,7 @@ def gather_constraints() -> str: *gather_recursive_requirements("default_config"), *gather_recursive_requirements("mqtt"), }, - key=lambda name: name.lower(), + key=str.lower, ) + [""] ) From b69cc29a78714ef465c4aefb97ca08e011cce8fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 15:45:45 -0500 Subject: [PATCH 180/984] Switch lambda to attrgetter in zha (#99660) --- homeassistant/components/zha/core/registries.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 03fdc7e37c1..713d10ddf70 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -4,6 +4,7 @@ from __future__ import annotations import collections from collections.abc import Callable import dataclasses +from operator import attrgetter from typing import TYPE_CHECKING, TypeVar import attr @@ -111,6 +112,8 @@ CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[ ] = DictRegistry() ZIGBEE_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClusterHandler]] = DictRegistry() +WEIGHT_ATTR = attrgetter("weight") + def set_or_callable(value) -> frozenset[str] | Callable: """Convert single str or None to a set. Pass through callables and sets.""" @@ -294,7 +297,7 @@ class ZHAEntityRegistry: ) -> tuple[type[ZhaEntity] | None, list[ClusterHandler]]: """Match a ZHA ClusterHandler to a ZHA Entity class.""" matches = self._strict_registry[component] - for match in sorted(matches, key=lambda x: x.weight, reverse=True): + for match in sorted(matches, key=WEIGHT_ATTR, reverse=True): if match.strict_matched(manufacturer, model, cluster_handlers, quirk_class): claimed = match.claim_cluster_handlers(cluster_handlers) return self._strict_registry[component][match], claimed @@ -315,7 +318,7 @@ class ZHAEntityRegistry: all_claimed: set[ClusterHandler] = set() for component, stop_match_groups in self._multi_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): - sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) + sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( manufacturer, model, cluster_handlers, quirk_class @@ -349,7 +352,7 @@ class ZHAEntityRegistry: stop_match_groups, ) in self._config_diagnostic_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): - sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) + sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( manufacturer, model, cluster_handlers, quirk_class From a2dae601708ff235ff1239d7c9e51afcadd07fe0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 20:18:27 -0500 Subject: [PATCH 181/984] Refactor dispatcher to reduce run time and memory overhead (#99676) * Fix memory leak in dispatcher removal When we removed the last job/callable from the dict for the signal we did not remove the dict for the signal which meant it leaked * comment * cleanup a bit more --- homeassistant/helpers/dispatcher.py | 69 +++++++++++++++++++---------- tests/helpers/test_dispatcher.py | 22 +++++++++ 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 60aab156144..e416d939914 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +from functools import partial import logging from typing import Any @@ -13,6 +14,14 @@ from homeassistant.util.logging import catch_log_exception _LOGGER = logging.getLogger(__name__) DATA_DISPATCHER = "dispatcher" +_DispatcherDataType = dict[ + str, + dict[ + Callable[..., Any], + HassJob[..., None | Coroutine[Any, Any, None]] | None, + ], +] + @bind_hass def dispatcher_connect( @@ -30,6 +39,26 @@ def dispatcher_connect( return remove_dispatcher +@callback +def _async_remove_dispatcher( + dispatchers: _DispatcherDataType, + signal: str, + target: Callable[..., Any], +) -> None: + """Remove signal listener.""" + try: + signal_dispatchers = dispatchers[signal] + del signal_dispatchers[target] + # Cleanup the signal dict if it is now empty + # to prevent memory leaks + if not signal_dispatchers: + del dispatchers[signal] + except (KeyError, ValueError): + # KeyError is key target listener did not exist + # ValueError if listener did not exist within signal + _LOGGER.warning("Unable to remove unknown dispatcher %s", target) + + @callback @bind_hass def async_dispatcher_connect( @@ -41,19 +70,18 @@ def async_dispatcher_connect( """ if DATA_DISPATCHER not in hass.data: hass.data[DATA_DISPATCHER] = {} - hass.data[DATA_DISPATCHER].setdefault(signal, {})[target] = None - @callback - def async_remove_dispatcher() -> None: - """Remove signal listener.""" - try: - del hass.data[DATA_DISPATCHER][signal][target] - except (KeyError, ValueError): - # KeyError is key target listener did not exist - # ValueError if listener did not exist within signal - _LOGGER.warning("Unable to remove unknown dispatcher %s", target) + dispatchers: _DispatcherDataType = hass.data[DATA_DISPATCHER] - return async_remove_dispatcher + if signal not in dispatchers: + dispatchers[signal] = {} + + dispatchers[signal][target] = None + # Use a partial for the remove since it uses + # less memory than a full closure since a partial copies + # the body of the function and we don't have to store + # many different copies of the same function + return partial(_async_remove_dispatcher, dispatchers, signal, target) @bind_hass @@ -87,21 +115,14 @@ def async_dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: This method must be run in the event loop. """ - target_list: dict[ - Callable[..., Any], HassJob[..., None | Coroutine[Any, Any, None]] | None - ] = hass.data.get(DATA_DISPATCHER, {}).get(signal, {}) + if (maybe_dispatchers := hass.data.get(DATA_DISPATCHER)) is None: + return + dispatchers: _DispatcherDataType = maybe_dispatchers + if (target_list := dispatchers.get(signal)) is None: + return - run: list[HassJob[..., None | Coroutine[Any, Any, None]]] = [] - for target, job in target_list.items(): + for target, job in list(target_list.items()): if job is None: job = _generate_job(signal, target) target_list[target] = job - - # Run the jobs all at the end - # to ensure no jobs add more dispatchers - # which can result in the target_list - # changing size during iteration - run.append(job) - - for job in run: hass.async_run_hass_job(job, *args) diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index e30aaa6e0d9..a251b20b0f4 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -151,3 +151,25 @@ async def test_callback_exception_gets_logged( f"Exception in functools.partial({bad_handler}) when dispatching 'test': ('bad',)" in caplog.text ) + + +async def test_dispatcher_add_dispatcher(hass: HomeAssistant) -> None: + """Test adding a dispatcher from a dispatcher.""" + calls = [] + + @callback + def _new_dispatcher(data): + calls.append(data) + + @callback + def _add_new_dispatcher(data): + calls.append(data) + async_dispatcher_connect(hass, "test", _new_dispatcher) + + async_dispatcher_connect(hass, "test", _add_new_dispatcher) + + async_dispatcher_send(hass, "test", 3) + async_dispatcher_send(hass, "test", 4) + async_dispatcher_send(hass, "test", 5) + + assert calls == [3, 4, 4, 5, 5] From e22b03d6b3a84c66d35d411367d35536d76a1936 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 20:18:46 -0500 Subject: [PATCH 182/984] Switch homekit config flow sorted to use itemgetter (#99658) Avoids unnecessary lambda --- homeassistant/components/homekit/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 3747af3edc7..c43093d92b4 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable from copy import deepcopy +from operator import itemgetter import random import re import string @@ -638,7 +639,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: for device_id in results: entry = dev_reg.async_get(device_id) unsorted[device_id] = entry.name or device_id if entry else device_id - return dict(sorted(unsorted.items(), key=lambda item: item[1])) + return dict(sorted(unsorted.items(), key=itemgetter(1))) def _exclude_by_entity_registry( From da45f6cbb03f6c3fb96a3cc202e90b6009f040e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 20:42:50 -0500 Subject: [PATCH 183/984] Bump aioesphomeapi to 16.0.5 (#99698) changelog: https://github.com/esphome/aioesphomeapi/compare/v16.0.4...v16.0.5 fixes `RuntimeError: set changed size during iteration` https://github.com/esphome/aioesphomeapi/pull/538 some added debug logging which may help with https://github.com/home-assistant/core/issues/98221 --- 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 32d915f8b76..e311a0913ae 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==16.0.4", + "aioesphomeapi==16.0.5", "bluetooth-data-tools==1.11.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 98e597ae6e3..c900fb37c38 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -232,7 +232,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.4 +aioesphomeapi==16.0.5 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2999d0fd07..f3d8fc5d322 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -213,7 +213,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.4 +aioesphomeapi==16.0.5 # homeassistant.components.flo aioflo==2021.11.0 From 4f05e610728a6e61a74db36357db480f311087db Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 04:14:56 +0200 Subject: [PATCH 184/984] Add codeowner for Withings (#99681) --- CODEOWNERS | 4 ++-- homeassistant/components/withings/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 42537d4e3f1..79ff912f4b7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1416,8 +1416,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj /homeassistant/components/wirelesstag/ @sergeymaysak -/homeassistant/components/withings/ @vangorra -/tests/components/withings/ @vangorra +/homeassistant/components/withings/ @vangorra @joostlek +/tests/components/withings/ @vangorra @joostlek /homeassistant/components/wiz/ @sbidy /tests/components/wiz/ @sbidy /homeassistant/components/wled/ @frenck diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 29201c7e66e..325205cb4d4 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -1,7 +1,7 @@ { "domain": "withings", "name": "Withings", - "codeowners": ["@vangorra"], + "codeowners": ["@vangorra", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/withings", From d9a1ebafddc32ab8efcccc2ab5de3d3424569188 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 6 Sep 2023 06:17:45 +0000 Subject: [PATCH 185/984] Show OTA update progress for Shelly gen2 devices (#99534) * Show OTA update progress * Use an event listener instead of a dispatcher * Add tests * Fix name * Improve tests coverage * Fix subscribe/unsubscribe logic * Use async_on_remove() --- homeassistant/components/shelly/const.py | 5 + .../components/shelly/coordinator.py | 21 ++++ homeassistant/components/shelly/update.py | 34 +++-- tests/components/shelly/test_update.py | 119 ++++++++++++++++-- 4 files changed, 159 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 33b4caa5034..0275b805208 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -181,3 +181,8 @@ PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" GAS_VALVE_OPEN_STATES = ("opening", "opened") + +OTA_BEGIN = "ota_begin" +OTA_ERROR = "ota_error" +OTA_PROGRESS = "ota_progress" +OTA_SUCCESS = "ota_success" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d645b09799f..d0530efa149 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -44,6 +44,10 @@ from .const import ( LOGGER, MAX_PUSH_UPDATE_FAILURES, MODELS_SUPPORTING_LIGHT_EFFECTS, + OTA_BEGIN, + OTA_ERROR, + OTA_PROGRESS, + OTA_SUCCESS, PUSH_UPDATE_ISSUE_ID, REST_SENSORS_UPDATE_INTERVAL, RPC_INPUTS_EVENTS_TYPES, @@ -384,6 +388,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._disconnected_callbacks: list[CALLBACK_TYPE] = [] self._connection_lock = asyncio.Lock() self._event_listeners: list[Callable[[dict[str, Any]], None]] = [] + self._ota_event_listeners: list[Callable[[dict[str, Any]], None]] = [] entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @@ -408,6 +413,19 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): return True + @callback + def async_subscribe_ota_events( + self, ota_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to OTA events.""" + + def _unsubscribe() -> None: + self._ota_event_listeners.remove(ota_event_callback) + + self._ota_event_listeners.append(ota_event_callback) + + return _unsubscribe + @callback def async_subscribe_events( self, event_callback: Callable[[dict[str, Any]], None] @@ -461,6 +479,9 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ATTR_GENERATION: 2, }, ) + elif event_type in (OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS): + for event_callback in self._ota_event_listeners: + event_callback(event) async def _async_update_data(self) -> None: """Fetch data.""" diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 3b2096f0c1a..d4528f55288 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -18,12 +18,12 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD +from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator from .entity import ( RestEntityDescription, @@ -229,7 +229,28 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): ) -> None: """Initialize update entity.""" super().__init__(coordinator, key, attribute, description) - self._in_progress_old_version: str | None = None + self._ota_in_progress: bool = False + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_ota_events(self._ota_progress_callback) + ) + + @callback + def _ota_progress_callback(self, event: dict[str, Any]) -> None: + """Handle device OTA progress.""" + if self._ota_in_progress: + event_type = event["event"] + if event_type == OTA_BEGIN: + self._attr_in_progress = 0 + elif event_type == OTA_PROGRESS: + self._attr_in_progress = event["progress_percent"] + elif event_type in (OTA_ERROR, OTA_SUCCESS): + self._attr_in_progress = False + self._ota_in_progress = False + self.async_write_ha_state() @property def installed_version(self) -> str | None: @@ -245,16 +266,10 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): return self.installed_version - @property - def in_progress(self) -> bool: - """Update installation in progress.""" - return self._in_progress_old_version == self.installed_version - async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install the latest firmware version.""" - self._in_progress_old_version = self.installed_version beta = self.entity_description.beta update_data = self.coordinator.device.status["sys"]["available_updates"] LOGGER.debug("OTA update service - update_data: %s", update_data) @@ -280,6 +295,7 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): except InvalidAuthError: self.coordinator.entry.async_start_reauth(self.hass) else: + self._ota_in_progress = True LOGGER.debug("OTA update call successful") diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 1ff2ac99814..454afb73ce1 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -29,6 +29,7 @@ from homeassistant.helpers.entity_registry import async_get from . import ( MOCK_MAC, init_integration, + inject_rpc_device_event, mock_rest_update, register_device, register_entity, @@ -222,6 +223,7 @@ async def test_block_update_auth_error( async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: """Test RPC device update entity.""" + entity_id = "update.test_name_firmware_update" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -232,7 +234,7 @@ async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> ) await init_integration(hass, 2) - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -243,21 +245,68 @@ async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_begin", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" - assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_IN_PROGRESS] == 0 + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_progress", + "id": 1, + "ts": 1668522399.2, + "progress_percent": 50, + } + ], + "ts": 1668522399.2, + }, + ) + + assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] == 50 + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_success", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -401,6 +450,7 @@ async def test_rpc_beta_update( suggested_object_id="test_name_beta_firmware_update", disabled_by=None, ) + entity_id = "update.test_name_beta_firmware_update" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -412,7 +462,7 @@ async def test_rpc_beta_update( ) await init_integration(hass, 2) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "1" @@ -428,7 +478,7 @@ async def test_rpc_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -437,21 +487,68 @@ async def test_rpc_beta_update( await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_beta_firmware_update"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_begin", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" - assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_IN_PROGRESS] == 0 + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_progress", + "id": 1, + "ts": 1668522399.2, + "progress_percent": 40, + } + ], + "ts": 1668522399.2, + }, + ) + + assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] == 40 + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_success", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") await mock_rest_update(hass, freezer) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" assert state.attributes[ATTR_LATEST_VERSION] == "2b" From d523734db1b60cc4707cf798faa90b0402b84d48 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 6 Sep 2023 09:35:34 +0300 Subject: [PATCH 186/984] Display channel number in Bravia TV if title is not available (#99567) Display channel number if title is not available --- homeassistant/components/braviatv/coordinator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 9b89c667b3c..20b30d1dd11 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -191,9 +191,11 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): if self.media_uri[:8] == "extInput": self.source = playing_info.get("title") if self.media_uri[:2] == "tv": - self.media_title = playing_info.get("programTitle") - self.media_channel = playing_info.get("title") self.media_content_id = playing_info.get("dispNum") + self.media_title = ( + playing_info.get("programTitle") or self.media_content_id + ) + self.media_channel = playing_info.get("title") or self.media_content_id self.media_content_type = MediaType.CHANNEL if not playing_info: self.media_title = "Smart TV" From d4ef570b0a40907a4ca15e0def185fc7471c1cfe Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 5 Sep 2023 23:43:46 -0700 Subject: [PATCH 187/984] Add a comment why state_class=total (#99703) --- homeassistant/components/opower/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 6be74deaebf..175bef01449 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -45,6 +45,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( name="Current bill electric usage to date", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + # Not TOTAL_INCREASING because it can decrease for accounts with solar state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.usage_to_date, From b28fda2433cf911ff64b761f7490d50e3ae386bf Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Sep 2023 08:54:25 +0200 Subject: [PATCH 188/984] Move template coordinator to its own file (#99419) * Move template update coordinator to its own file * Add coordinator.py to .coveragerc * Remove coordinator.py to .coveragerc * Apply suggestions from code review * Update homeassistant/components/template/coordinator.py * Copy over fixes from upstream --------- Co-authored-by: Erik Montnemery Co-authored-by: G Johansson --- homeassistant/components/template/__init__.py | 99 +------------------ .../components/template/coordinator.py | 94 ++++++++++++++++++ 2 files changed, 99 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/template/coordinator.py diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index c4ba7081f5a..22919ac9e70 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -2,30 +2,21 @@ from __future__ import annotations import asyncio -from collections.abc import Callable import logging from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_UNIQUE_ID, - EVENT_HOMEASSISTANT_START, - SERVICE_RELOAD, -) -from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall, callback +from homeassistant.const import CONF_UNIQUE_ID, SERVICE_RELOAD +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - discovery, - trigger as trigger_helper, - update_coordinator, -) +from homeassistant.helpers import discovery from homeassistant.helpers.reload import async_reload_integration_platforms -from homeassistant.helpers.script import Script from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration -from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +from .coordinator import TriggerUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -121,83 +112,3 @@ async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: if coordinator_tasks: hass.data[DOMAIN] = await asyncio.gather(*coordinator_tasks) - - -class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): - """Class to handle incoming data.""" - - REMOVE_TRIGGER = object() - - def __init__(self, hass, config): - """Instantiate trigger data.""" - super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") - self.config = config - self._unsub_start: Callable[[], None] | None = None - self._unsub_trigger: Callable[[], None] | None = None - self._script: Script | None = None - - @property - def unique_id(self) -> str | None: - """Return unique ID for the entity.""" - return self.config.get("unique_id") - - @callback - def async_remove(self): - """Signal that the entities need to remove themselves.""" - if self._unsub_start: - self._unsub_start() - if self._unsub_trigger: - self._unsub_trigger() - - async def async_setup(self, hass_config: ConfigType) -> None: - """Set up the trigger and create entities.""" - if self.hass.state == CoreState.running: - await self._attach_triggers() - else: - self._unsub_start = self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._attach_triggers - ) - - for platform_domain in PLATFORMS: - if platform_domain in self.config: - self.hass.async_create_task( - discovery.async_load_platform( - self.hass, - platform_domain, - DOMAIN, - {"coordinator": self, "entities": self.config[platform_domain]}, - hass_config, - ) - ) - - async def _attach_triggers(self, start_event=None) -> None: - """Attach the triggers.""" - if CONF_ACTION in self.config: - self._script = Script( - self.hass, - self.config[CONF_ACTION], - self.name, - DOMAIN, - ) - - if start_event is not None: - self._unsub_start = None - - self._unsub_trigger = await trigger_helper.async_initialize_triggers( - self.hass, - self.config[CONF_TRIGGER], - self._handle_triggered, - DOMAIN, - self.name, - self.logger.log, - start_event is not None, - ) - - async def _handle_triggered(self, run_variables, context=None): - if self._script: - script_result = await self._script.async_run(run_variables, context) - if script_result: - run_variables = script_result.variables - self.async_set_updated_data( - {"run_variables": run_variables, "context": context} - ) diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py new file mode 100644 index 00000000000..7f24fe731cc --- /dev/null +++ b/homeassistant/components/template/coordinator.py @@ -0,0 +1,94 @@ +"""Data update coordinator for trigger based template entities.""" +from collections.abc import Callable +import logging + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import CoreState, callback +from homeassistant.helpers import discovery, trigger as trigger_helper +from homeassistant.helpers.script import Script +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +class TriggerUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for trigger based template entities.""" + + REMOVE_TRIGGER = object() + + def __init__(self, hass, config): + """Instantiate trigger data.""" + super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") + self.config = config + self._unsub_start: Callable[[], None] | None = None + self._unsub_trigger: Callable[[], None] | None = None + self._script: Script | None = None + + @property + def unique_id(self) -> str | None: + """Return unique ID for the entity.""" + return self.config.get("unique_id") + + @callback + def async_remove(self): + """Signal that the entities need to remove themselves.""" + if self._unsub_start: + self._unsub_start() + if self._unsub_trigger: + self._unsub_trigger() + + async def async_setup(self, hass_config: ConfigType) -> None: + """Set up the trigger and create entities.""" + if self.hass.state == CoreState.running: + await self._attach_triggers() + else: + self._unsub_start = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._attach_triggers + ) + + for platform_domain in PLATFORMS: + if platform_domain in self.config: + self.hass.async_create_task( + discovery.async_load_platform( + self.hass, + platform_domain, + DOMAIN, + {"coordinator": self, "entities": self.config[platform_domain]}, + hass_config, + ) + ) + + async def _attach_triggers(self, start_event=None) -> None: + """Attach the triggers.""" + if CONF_ACTION in self.config: + self._script = Script( + self.hass, + self.config[CONF_ACTION], + self.name, + DOMAIN, + ) + + if start_event is not None: + self._unsub_start = None + + self._unsub_trigger = await trigger_helper.async_initialize_triggers( + self.hass, + self.config[CONF_TRIGGER], + self._handle_triggered, + DOMAIN, + self.name, + self.logger.log, + start_event is not None, + ) + + async def _handle_triggered(self, run_variables, context=None): + if self._script: + script_result = await self._script.async_run(run_variables, context) + if script_result: + run_variables = script_result.variables + self.async_set_updated_data( + {"run_variables": run_variables, "context": context} + ) From cdca4591a4e25922f4d4af4c0c2a96256eafb35d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 09:49:42 +0200 Subject: [PATCH 189/984] Include template listener info in template preview (#99669) --- .../components/template/config_flow.py | 3 +- .../components/template/template_entity.py | 34 ++++++++++++++----- .../helpers/trigger_template_entity.py | 6 ++-- tests/components/template/test_config_flow.py | 27 +++++++++++++++ 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index ccc06989c71..093cbf14098 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -349,6 +349,7 @@ def ws_start_preview( def async_preview_updated( state: str | None, attributes: Mapping[str, Any] | None, + listeners: dict[str, bool | set[str]] | None, error: str | None, ) -> None: """Forward config entry state events to websocket.""" @@ -363,7 +364,7 @@ def ws_start_preview( connection.send_message( websocket_api.event_message( msg["id"], - {"attributes": attributes, "state": state}, + {"attributes": attributes, "listeners": listeners, "state": state}, ) ) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index c33674fa86f..2ce42083117 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -34,6 +34,7 @@ from homeassistant.helpers.event import ( EventStateChangedData, TrackTemplate, TrackTemplateResult, + TrackTemplateResultInfo, async_track_template_result, ) from homeassistant.helpers.script import Script, _VarsType @@ -260,12 +261,18 @@ class TemplateEntity(Entity): ) -> None: """Template Entity.""" self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} - self._async_update: Callable[[], None] | None = None + self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} self._self_ref_update_count = 0 self._attr_unique_id = unique_id self._preview_callback: Callable[ - [str | None, dict[str, Any] | None, str | None], None + [ + str | None, + dict[str, Any] | None, + dict[str, bool | set[str]] | None, + str | None, + ], + None, ] | None = None if config is None: self._attribute_templates = attribute_templates @@ -427,9 +434,12 @@ class TemplateEntity(Entity): state, attrs = self._async_generate_attributes() validate_state(state) except Exception as err: # pylint: disable=broad-exception-caught - self._preview_callback(None, None, str(err)) + self._preview_callback(None, None, None, str(err)) else: - self._preview_callback(state, attrs, None) + assert self._template_result_info + self._preview_callback( + state, attrs, self._template_result_info.listeners, None + ) @callback def _async_template_startup(self, *_: Any) -> None: @@ -460,7 +470,7 @@ class TemplateEntity(Entity): has_super_template=has_availability_template, ) self.async_on_remove(result_info.async_remove) - self._async_update = result_info.async_refresh + self._template_result_info = result_info result_info.async_refresh() @callback @@ -494,7 +504,13 @@ class TemplateEntity(Entity): def async_start_preview( self, preview_callback: Callable[ - [str | None, Mapping[str, Any] | None, str | None], None + [ + str | None, + Mapping[str, Any] | None, + dict[str, bool | set[str]] | None, + str | None, + ], + None, ], ) -> CALLBACK_TYPE: """Render a preview.""" @@ -504,7 +520,7 @@ class TemplateEntity(Entity): try: self._async_template_startup() except Exception as err: # pylint: disable=broad-exception-caught - preview_callback(None, None, str(err)) + preview_callback(None, None, None, str(err)) return self._call_on_remove_callbacks async def async_added_to_hass(self) -> None: @@ -521,8 +537,8 @@ class TemplateEntity(Entity): async def async_update(self) -> None: """Call for forced update.""" - assert self._async_update - self._async_update() + assert self._template_result_info + self._template_result_info.async_refresh() async def async_run_script( self, diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 8fc99f5cb52..0ee653b42bd 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -77,8 +77,8 @@ class TriggerBaseEntity(Entity): """Template Base entity based on trigger data.""" domain: str - extra_template_keys: tuple | None = None - extra_template_keys_complex: tuple | None = None + extra_template_keys: tuple[str, ...] | None = None + extra_template_keys_complex: tuple[str, ...] | None = None _unique_id: str | None def __init__( @@ -94,7 +94,7 @@ class TriggerBaseEntity(Entity): self._config = config self._static_rendered = {} - self._to_render_simple = [] + self._to_render_simple: list[str] = [] self._to_render_complex: list[str] = [] for itm in ( diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index ba939f3b8d1..b8634b68b1c 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import patch import pytest +from pytest_unordered import unordered from homeassistant import config_entries from homeassistant.components.template import DOMAIN, async_setup_entry @@ -257,6 +258,7 @@ async def test_options( "input_states", "template_states", "extra_attributes", + "listeners", ), ( ( @@ -266,6 +268,7 @@ async def test_options( {"one": "on", "two": "off"}, ["off", "on"], [{}, {}], + [["one", "two"], ["one"]], ), ( "sensor", @@ -274,6 +277,7 @@ async def test_options( {"one": "30.0", "two": "20.0"}, ["unavailable", "50.0"], [{}, {}], + [["one"], ["one", "two"]], ), ), ) @@ -286,6 +290,7 @@ async def test_config_flow_preview( input_states: list[str], template_states: str, extra_attributes: list[dict[str, Any]], + listeners: list[list[str]], ) -> None: """Test the config flow preview.""" client = await hass_ws_client(hass) @@ -323,6 +328,12 @@ async def test_config_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes[0], + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners[0]]), + "time": False, + }, "state": template_states[0], } @@ -336,6 +347,12 @@ async def test_config_flow_preview( "attributes": {"friendly_name": "My template"} | extra_attributes[0] | extra_attributes[1], + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners[1]]), + "time": False, + }, "state": template_states[1], } assert len(hass.states.async_all()) == 2 @@ -526,6 +543,7 @@ async def test_config_flow_preview_bad_state( "input_states", "template_state", "extra_attributes", + "listeners", ), [ ( @@ -537,6 +555,7 @@ async def test_config_flow_preview_bad_state( {"one": "on", "two": "off"}, "off", {}, + ["one", "two"], ), ( "sensor", @@ -547,6 +566,7 @@ async def test_config_flow_preview_bad_state( {"one": "30.0", "two": "20.0"}, "10.0", {}, + ["one", "two"], ), ], ) @@ -561,6 +581,7 @@ async def test_option_flow_preview( input_states: list[str], template_state: str, extra_attributes: dict[str, Any], + listeners: list[str], ) -> None: """Test the option flow preview.""" client = await hass_ws_client(hass) @@ -608,6 +629,12 @@ async def test_option_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes, + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners]), + "time": False, + }, "state": template_state, } assert len(hass.states.async_all()) == 3 From f41b0452442374c8cfb5f94ecde7dc81ead4ec90 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Sep 2023 09:55:25 +0200 Subject: [PATCH 190/984] Use shorthand attributes in Trend (#99695) --- CODEOWNERS | 2 ++ homeassistant/components/trend/binary_sensor.py | 16 +++------------- homeassistant/components/trend/manifest.json | 4 ++-- homeassistant/generated/integrations.json | 2 +- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 79ff912f4b7..58812a0baf2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1314,6 +1314,8 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST /homeassistant/components/transmission/ @engrbm87 @JPHutchins /tests/components/transmission/ @engrbm87 @JPHutchins +/homeassistant/components/trend/ @jpbede +/tests/components/trend/ @jpbede /homeassistant/components/tts/ @home-assistant/core @pvizeli /tests/components/tts/ @home-assistant/core @pvizeli /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 020f7903060..815403e1e87 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -134,10 +134,10 @@ class SensorTrend(BinarySensorEntity): """Initialize the sensor.""" self._hass = hass self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - self._name = friendly_name + self._attr_name = friendly_name + self._attr_device_class = device_class self._entity_id = entity_id self._attribute = attribute - self._device_class = device_class self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient @@ -145,27 +145,17 @@ class SensorTrend(BinarySensorEntity): self._state = None self.samples = deque(maxlen=max_samples) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def is_on(self): """Return true if sensor is on.""" return self._state - @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, - ATTR_FRIENDLY_NAME: self._name, + ATTR_FRIENDLY_NAME: self._attr_name, ATTR_GRADIENT: self._gradient, ATTR_INVERT: self._invert, ATTR_MIN_GRADIENT: self._min_gradient, diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 77a0044ca1f..9bb5c4296c5 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -1,9 +1,9 @@ { "domain": "trend", "name": "Trend", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/trend", - "iot_class": "local_push", + "iot_class": "calculated", "quality_scale": "internal", "requirements": ["numpy==1.23.2"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index acf12b4f05d..39c7a82ce55 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5930,7 +5930,7 @@ "name": "Trend", "integration_type": "hub", "config_flow": false, - "iot_class": "local_push" + "iot_class": "calculated" }, "tuya": { "name": "Tuya", From 48f7924e9e762ccde7f86f9cce866a7b12d943bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 10:03:35 +0200 Subject: [PATCH 191/984] Allow specifying a custom log function for template render (#99572) * Allow specifying a custom log function for template render * Bypass template cache when reporting errors + fix tests * Send errors as events * Fix logic for creating new TemplateEnvironment * Add strict mode back * Only send error events if report_errors is True * Force test of websocket_api only * Debug test * Run pytest with higher verbosity * Timeout after 1 minute, enable syslog output * Adjust timeout * Add debug logs * Fix unsafe call to WebSocketHandler._send_message * Remove debug code * Improve test coverage * Revert accidental change * Include severity in error events * Remove redundant information from error events --- .../components/websocket_api/commands.py | 32 +- homeassistant/helpers/event.py | 17 +- homeassistant/helpers/template.py | 113 ++++--- .../components/websocket_api/test_commands.py | 278 ++++++++++++++++-- tests/helpers/test_template.py | 18 +- 5 files changed, 374 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 84c7567a40e..7772bef66f9 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -5,6 +5,7 @@ from collections.abc import Callable import datetime as dt from functools import lru_cache, partial import json +import logging from typing import Any, cast import voluptuous as vol @@ -505,6 +506,7 @@ def _cached_template(template_str: str, hass: HomeAssistant) -> template.Templat vol.Optional("variables"): dict, vol.Optional("timeout"): vol.Coerce(float), vol.Optional("strict", default=False): bool, + vol.Optional("report_errors", default=False): bool, } ) @decorators.async_response @@ -513,14 +515,32 @@ async def handle_render_template( ) -> None: """Handle render_template command.""" template_str = msg["template"] - template_obj = _cached_template(template_str, hass) + report_errors: bool = msg["report_errors"] + if report_errors: + template_obj = template.Template(template_str, hass) + else: + template_obj = _cached_template(template_str, hass) variables = msg.get("variables") timeout = msg.get("timeout") + @callback + def _error_listener(level: int, template_error: str) -> None: + connection.send_message( + messages.event_message( + msg["id"], + {"error": template_error, "level": logging.getLevelName(level)}, + ) + ) + + @callback + def _thread_safe_error_listener(level: int, template_error: str) -> None: + hass.loop.call_soon_threadsafe(_error_listener, level, template_error) + if timeout: try: + log_fn = _thread_safe_error_listener if report_errors else None timed_out = await template_obj.async_render_will_timeout( - timeout, variables, strict=msg["strict"] + timeout, variables, strict=msg["strict"], log_fn=log_fn ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) @@ -542,7 +562,11 @@ async def handle_render_template( track_template_result = updates.pop() result = track_template_result.result if isinstance(result, TemplateError): - connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(result)) + if not report_errors: + return + connection.send_message( + messages.event_message(msg["id"], {"error": str(result)}) + ) return connection.send_message( @@ -552,12 +576,14 @@ async def handle_render_template( ) try: + log_fn = _error_listener if report_errors else None info = async_track_template_result( hass, [TrackTemplate(template_obj, variables)], _template_listener, raise_on_template_error=True, strict=msg["strict"], + log_fn=log_fn, ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b8831d38d86..22e274a7d0f 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -915,7 +915,12 @@ class TrackTemplateResultInfo: """Return the representation.""" return f"" - def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: + def async_setup( + self, + raise_on_template_error: bool, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: """Activation of template tracking.""" block_render = False super_template = self._track_templates[0] if self._has_super_template else None @@ -925,7 +930,7 @@ class TrackTemplateResultInfo: template = super_template.template variables = super_template.variables self._info[template] = info = template.async_render_to_info( - variables, strict=strict + variables, strict=strict, log_fn=log_fn ) # If the super template did not render to True, don't update other templates @@ -946,7 +951,7 @@ class TrackTemplateResultInfo: template = track_template_.template variables = track_template_.variables self._info[template] = info = template.async_render_to_info( - variables, strict=strict + variables, strict=strict, log_fn=log_fn ) if info.exception: @@ -1233,6 +1238,7 @@ def async_track_template_result( action: TrackTemplateResultListener, raise_on_template_error: bool = False, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, has_super_template: bool = False, ) -> TrackTemplateResultInfo: """Add a listener that fires when the result of a template changes. @@ -1264,6 +1270,9 @@ def async_track_template_result( tracking. strict When set to True, raise on undefined variables. + log_fn + If not None, template error messages will logging by calling log_fn + instead of the normal logging facility. has_super_template When set to True, the first template will block rendering of other templates if it doesn't render as True. @@ -1274,7 +1283,7 @@ def async_track_template_result( """ tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) - tracker.async_setup(raise_on_template_error, strict=strict) + tracker.async_setup(raise_on_template_error, strict=strict, log_fn=log_fn) return tracker diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index b5a6a45e97f..9f280db6c98 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -458,6 +458,7 @@ class Template: "_exc_info", "_limited", "_strict", + "_log_fn", "_hash_cache", "_renders", ) @@ -475,6 +476,7 @@ class Template: self._exc_info: sys._OptExcInfo | None = None self._limited: bool | None = None self._strict: bool | None = None + self._log_fn: Callable[[int, str], None] | None = None self._hash_cache: int = hash(self.template) self._renders: int = 0 @@ -482,6 +484,11 @@ class Template: def _env(self) -> TemplateEnvironment: if self.hass is None: return _NO_HASS_ENV + # Bypass cache if a custom log function is specified + if self._log_fn is not None: + return TemplateEnvironment( + self.hass, self._limited, self._strict, self._log_fn + ) if self._limited: wanted_env = _ENVIRONMENT_LIMITED elif self._strict: @@ -491,9 +498,7 @@ class Template: ret: TemplateEnvironment | None = self.hass.data.get(wanted_env) if ret is None: ret = self.hass.data[wanted_env] = TemplateEnvironment( - self.hass, - self._limited, - self._strict, + self.hass, self._limited, self._strict, self._log_fn ) return ret @@ -537,6 +542,7 @@ class Template: parse_result: bool = True, limited: bool = False, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, **kwargs: Any, ) -> Any: """Render given template. @@ -553,7 +559,7 @@ class Template: return self.template return self._parse_result(self.template) - compiled = self._compiled or self._ensure_compiled(limited, strict) + compiled = self._compiled or self._ensure_compiled(limited, strict, log_fn) if variables is not None: kwargs.update(variables) @@ -608,6 +614,7 @@ class Template: timeout: float, variables: TemplateVarsType = None, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, **kwargs: Any, ) -> bool: """Check to see if rendering a template will timeout during render. @@ -628,7 +635,7 @@ class Template: if self.is_static: return False - compiled = self._compiled or self._ensure_compiled(strict=strict) + compiled = self._compiled or self._ensure_compiled(strict=strict, log_fn=log_fn) if variables is not None: kwargs.update(variables) @@ -664,7 +671,11 @@ class Template: @callback def async_render_to_info( - self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any + self, + variables: TemplateVarsType = None, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, + **kwargs: Any, ) -> RenderInfo: """Render the template and collect an entity filter.""" self._renders += 1 @@ -680,7 +691,9 @@ class Template: token = _render_info.set(render_info) try: - render_info._result = self.async_render(variables, strict=strict, **kwargs) + render_info._result = self.async_render( + variables, strict=strict, log_fn=log_fn, **kwargs + ) except TemplateError as ex: render_info.exception = ex finally: @@ -743,7 +756,10 @@ class Template: return value if error_value is _SENTINEL else error_value def _ensure_compiled( - self, limited: bool = False, strict: bool = False + self, + limited: bool = False, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, ) -> jinja2.Template: """Bind a template to a specific hass instance.""" self.ensure_valid() @@ -756,10 +772,14 @@ class Template: self._strict is None or self._strict == strict ), "can't change between strict and non strict template" assert not (strict and limited), "can't combine strict and limited template" + assert ( + self._log_fn is None or self._log_fn == log_fn + ), "can't change custom log function" assert self._compiled_code is not None, "template code was not compiled" self._limited = limited self._strict = strict + self._log_fn = log_fn env = self._env self._compiled = jinja2.Template.from_code( @@ -2178,45 +2198,56 @@ def _render_with_context( return template.render(**kwargs) -class LoggingUndefined(jinja2.Undefined): +def make_logging_undefined( + strict: bool | None, log_fn: Callable[[int, str], None] | None +) -> type[jinja2.Undefined]: """Log on undefined variables.""" - def _log_message(self) -> None: + if strict: + return jinja2.StrictUndefined + + def _log_with_logger(level: int, msg: str) -> None: template, action = template_cv.get() or ("", "rendering or compiling") - _LOGGER.warning( - "Template variable warning: %s when %s '%s'", - self._undefined_message, + _LOGGER.log( + level, + "Template variable %s: %s when %s '%s'", + logging.getLevelName(level).lower(), + msg, action, template, ) - def _fail_with_undefined_error(self, *args, **kwargs): - try: - return super()._fail_with_undefined_error(*args, **kwargs) - except self._undefined_exception as ex: - template, action = template_cv.get() or ("", "rendering or compiling") - _LOGGER.error( - "Template variable error: %s when %s '%s'", - self._undefined_message, - action, - template, - ) - raise ex + _log_fn = log_fn or _log_with_logger - def __str__(self) -> str: - """Log undefined __str___.""" - self._log_message() - return super().__str__() + class LoggingUndefined(jinja2.Undefined): + """Log on undefined variables.""" - def __iter__(self): - """Log undefined __iter___.""" - self._log_message() - return super().__iter__() + def _log_message(self) -> None: + _log_fn(logging.WARNING, self._undefined_message) - def __bool__(self) -> bool: - """Log undefined __bool___.""" - self._log_message() - return super().__bool__() + def _fail_with_undefined_error(self, *args, **kwargs): + try: + return super()._fail_with_undefined_error(*args, **kwargs) + except self._undefined_exception as ex: + _log_fn(logging.ERROR, self._undefined_message) + raise ex + + def __str__(self) -> str: + """Log undefined __str___.""" + self._log_message() + return super().__str__() + + def __iter__(self): + """Log undefined __iter___.""" + self._log_message() + return super().__iter__() + + def __bool__(self) -> bool: + """Log undefined __bool___.""" + self._log_message() + return super().__bool__() + + return LoggingUndefined async def async_load_custom_templates(hass: HomeAssistant) -> None: @@ -2281,14 +2312,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): hass: HomeAssistant | None, limited: bool | None = False, strict: bool | None = False, + log_fn: Callable[[int, str], None] | None = None, ) -> None: """Initialise template environment.""" - undefined: type[LoggingUndefined] | type[jinja2.StrictUndefined] - if not strict: - undefined = LoggingUndefined - else: - undefined = jinja2.StrictUndefined - super().__init__(undefined=undefined) + super().__init__(undefined=make_logging_undefined(strict, log_fn)) self.hass = hass self.template_cache: weakref.WeakValueDictionary[ str | jinja2.nodes.Template, CodeType | str | None diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 73baa968ab6..96e79a81716 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy import datetime +import logging from unittest.mock import ANY, AsyncMock, Mock, patch import pytest @@ -33,7 +34,11 @@ from tests.common import ( async_mock_service, mock_platform, ) -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import ( + ClientSessionGenerator, + MockHAClientWebSocket, + WebSocketGenerator, +) STATE_KEY_SHORT_NAMES = { "entity_id": "e", @@ -1225,46 +1230,187 @@ async def test_render_template_manual_entity_ids_no_longer_needed( } +EMPTY_LISTENERS = {"all": False, "entities": [], "domains": [], "time": False} + +ERR_MSG = {"type": "result", "success": False} + +VARIABLE_ERROR_UNDEFINED_FUNC = { + "error": "'my_unknown_func' is undefined", + "level": "ERROR", +} +TEMPLATE_ERROR_UNDEFINED_FUNC = { + "code": "template_error", + "message": "UndefinedError: 'my_unknown_func' is undefined", +} + +VARIABLE_WARNING_UNDEFINED_VAR = { + "error": "'my_unknown_var' is undefined", + "level": "WARNING", +} +TEMPLATE_ERROR_UNDEFINED_VAR = { + "code": "template_error", + "message": "UndefinedError: 'my_unknown_var' is undefined", +} + +TEMPLATE_ERROR_UNDEFINED_FILTER = { + "code": "template_error", + "message": "TemplateAssertionError: No filter named 'unknown_filter'.", +} + + @pytest.mark.parametrize( - "template", + ("template", "expected_events"), [ - "{{ my_unknown_func() + 1 }}", - "{{ my_unknown_var }}", - "{{ my_unknown_var + 1 }}", - "{{ now() | unknown_filter }}", + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, + ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + { + "type": "event", + "event": {"result": "", "listeners": EMPTY_LISTENERS}, + }, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), ], ) async def test_render_template_with_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture, template + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], ) -> None: """Test a template with an error.""" + caplog.set_level(logging.INFO) await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template, "strict": True} + { + "id": 5, + "type": "render_template", + "template": template, + "report_errors": True, + } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text @pytest.mark.parametrize( - "template", + ("template", "expected_events"), [ - "{{ my_unknown_func() + 1 }}", - "{{ my_unknown_var }}", - "{{ my_unknown_var + 1 }}", - "{{ now() | unknown_filter }}", + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, + ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + { + "type": "event", + "event": {"result": "", "listeners": EMPTY_LISTENERS}, + }, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), ], ) async def test_render_template_with_timeout_and_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture, template + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], ) -> None: """Test a template with an error with a timeout.""" + caplog.set_level(logging.INFO) + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template, + "timeout": 5, + "report_errors": True, + } + ) + + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text + assert "TemplateError" not in caplog.text + + +@pytest.mark.parametrize( + ("template", "expected_events"), + [ + ( + "{{ my_unknown_func() + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}], + ), + ( + "{{ my_unknown_var }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), + ], +) +async def test_render_template_strict_with_timeout_and_error( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], +) -> None: + """Test a template with an error with a timeout.""" + caplog.set_level(logging.INFO) await websocket_client.send_json( { "id": 5, @@ -1275,13 +1421,14 @@ async def test_render_template_with_timeout_and_error( } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text @@ -1299,13 +1446,19 @@ async def test_render_template_error_in_template_code( assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text async def test_render_template_with_delayed_error( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: - """Test a template with an error that only happens after a state change.""" + """Test a template with an error that only happens after a state change. + + In this test report_errors is enabled. + """ + caplog.set_level(logging.INFO) hass.states.async_set("sensor.test", "on") await hass.async_block_till_done() @@ -1318,12 +1471,16 @@ async def test_render_template_with_delayed_error( """ await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template_str} + { + "id": 5, + "type": "render_template", + "template": template_str, + "report_errors": True, + } ) await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1347,13 +1504,74 @@ async def test_render_template_with_delayed_error( msg = await websocket_client.receive_json() assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert msg["type"] == "event" + event = msg["event"] + assert event["error"] == "'None' has no attribute 'state'" + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + event = msg["event"] + assert event == {"error": "UndefinedError: 'explode' is undefined"} + + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text +async def test_render_template_with_delayed_error_2( + hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture +) -> None: + """Test a template with an error that only happens after a state change. + + In this test report_errors is disabled. + """ + hass.states.async_set("sensor.test", "on") + await hass.async_block_till_done() + + template_str = """ +{% if states.sensor.test.state %} + on +{% else %} + {{ explode + 1 }} +{% endif %} + """ + + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template_str, + "report_errors": False, + } + ) + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + hass.states.async_remove("sensor.test") + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + event = msg["event"] + assert event == { + "result": "on", + "listeners": { + "all": False, + "domains": [], + "entities": ["sensor.test"], + "time": False, + }, + } + + assert "Template variable warning" in caplog.text + + async def test_render_template_with_timeout( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d14496d321e..58e0c730165 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -4466,15 +4466,25 @@ async def test_parse_result(hass: HomeAssistant) -> None: assert template.Template(tpl, hass).async_render() == result -async def test_undefined_variable( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + "template_string", + [ + "{{ no_such_variable }}", + "{{ no_such_variable and True }}", + "{{ no_such_variable | join(', ') }}", + ], +) +async def test_undefined_symbol_warnings( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + template_string: str, ) -> None: """Test a warning is logged on undefined variables.""" - tpl = template.Template("{{ no_such_variable }}", hass) + tpl = template.Template(template_string, hass) assert tpl.async_render() == "" assert ( "Template variable warning: 'no_such_variable' is undefined when rendering " - "'{{ no_such_variable }}'" in caplog.text + f"'{template_string}'" in caplog.text ) From 687e69f7c33e78726bef3178d3a529adc446cea0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 10:35:04 +0200 Subject: [PATCH 192/984] Fix unit conversion for gas cost sensor (#99708) --- homeassistant/components/energy/sensor.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index ae92ee2de58..e9760a96aa4 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -377,11 +377,10 @@ class EnergyCostSensor(SensorEntity): if energy_price_unit is None: converted_energy_price = energy_price else: - if self._adapter.source_type == "grid": - converter: Callable[ - [float, str, str], float - ] = unit_conversion.EnergyConverter.convert - elif self._adapter.source_type in ("gas", "water"): + converter: Callable[[float, str, str], float] + if energy_unit in VALID_ENERGY_UNITS: + converter = unit_conversion.EnergyConverter.convert + else: converter = unit_conversion.VolumeConverter.convert converted_energy_price = converter( From 00ada69e0b7b4b65fca9372aa3d693830fd63860 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Sep 2023 10:40:05 +0200 Subject: [PATCH 193/984] Update frontend to 20230906.0 (#99715) --- 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 627b36a59b8..9e0bd3e5de9 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==20230905.0"] + "requirements": ["home-assistant-frontend==20230906.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c4492c90e9c..810f6d093bf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230905.0 +home-assistant-frontend==20230906.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c900fb37c38..04eddfd1de3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230905.0 +home-assistant-frontend==20230906.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3d8fc5d322..75dd6db70f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230905.0 +home-assistant-frontend==20230906.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 71afa0ff433fcb7a6173fe1b736f34cf81e6ad7b Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 6 Sep 2023 10:46:52 +0200 Subject: [PATCH 194/984] Yellow LED controls: rename LEDs (#99710) - reorder, to reflect placement on board, left to right (yellow, green, red) --- homeassistant/components/homeassistant_yellow/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index e5250f163ce..68e87c06024 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -27,9 +27,9 @@ "hardware_settings": { "title": "Configure hardware settings", "data": { - "disk_led": "Disk LED", - "heartbeat_led": "Heartbeat LED", - "power_led": "Power LED" + "heartbeat_led": "Yellow: system health LED", + "disk_led": "Green: activity LED", + "power_led": "Red: power LED" } }, "install_addon": { From 034fabe188c183e93f689c2a88790b93125ddd78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 04:04:49 -0500 Subject: [PATCH 195/984] Use loop time to set context (#99701) * Use loop time to set context loop time is faster than utcnow, and since its only used internally it can be switched without a breaking change * fix mocking --- homeassistant/helpers/entity.py | 11 ++++++----- tests/helpers/test_service.py | 7 ++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index e946c41d3b8..7bd510b6fa1 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -5,7 +5,7 @@ from abc import ABC import asyncio from collections.abc import Coroutine, Iterable, Mapping, MutableMapping from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta from enum import Enum, auto import functools as ft import logging @@ -41,7 +41,7 @@ from homeassistant.exceptions import ( NoEntitySpecifiedError, ) from homeassistant.loader import bind_hass -from homeassistant.util import dt as dt_util, ensure_unique_string, slugify +from homeassistant.util import ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData @@ -272,7 +272,7 @@ class Entity(ABC): # Context _context: Context | None = None - _context_set: datetime | None = None + _context_set: float | None = None # If entity is added to an entity platform _platform_state = EntityPlatformState.NOT_ADDED @@ -660,7 +660,7 @@ class Entity(ABC): def async_set_context(self, context: Context) -> None: """Set the context the entity currently operates under.""" self._context = context - self._context_set = dt_util.utcnow() + self._context_set = self.hass.loop.time() async def async_update_ha_state(self, force_refresh: bool = False) -> None: """Update Home Assistant with current state of entity. @@ -847,7 +847,8 @@ class Entity(ABC): if ( self._context_set is not None - and dt_util.utcnow() - self._context_set > self.context_recent_time + and hass.loop.time() - self._context_set + > self.context_recent_time.total_seconds() ): self._context = None self._context_set = None diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 803a57e12ed..03a8b5e11b2 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,5 +1,4 @@ """Test service helpers.""" -from collections import OrderedDict from collections.abc import Iterable from copy import deepcopy from typing import Any @@ -54,7 +53,7 @@ def mock_handle_entity_call(): @pytest.fixture -def mock_entities(hass): +def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]: """Return mock entities in an ordered dict.""" kitchen = MockEntity( entity_id="light.kitchen", @@ -80,11 +79,13 @@ def mock_entities(hass): should_poll=False, supported_features=(SUPPORT_B | SUPPORT_C), ) - entities = OrderedDict() + entities = {} entities[kitchen.entity_id] = kitchen entities[living_room.entity_id] = living_room entities[bedroom.entity_id] = bedroom entities[bathroom.entity_id] = bathroom + for entity in entities.values(): + entity.hass = hass return entities From 274507b5c9df0fca216d2e2c54c8f2ad5abd677a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 11:35:57 +0200 Subject: [PATCH 196/984] Fix pylint plugin test DeprecationWarning (#99711) --- tests/pylint/conftest.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index e8748434350..4a53f686c5a 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -1,6 +1,7 @@ """Configuration for pylint tests.""" -from importlib.machinery import SourceFileLoader +from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path +import sys from types import ModuleType from pylint.checkers import BaseChecker @@ -13,11 +14,17 @@ BASE_PATH = Path(__file__).parents[2] @pytest.fixture(name="hass_enforce_type_hints", scope="session") def hass_enforce_type_hints_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - loader = SourceFileLoader( - "hass_enforce_type_hints", + module_name = "hass_enforce_type_hints" + spec = spec_from_file_location( + module_name, str(BASE_PATH.joinpath("pylint/plugins/hass_enforce_type_hints.py")), ) - return loader.load_module(None) + assert spec and spec.loader + + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module @pytest.fixture(name="linter") @@ -37,11 +44,16 @@ def type_hint_checker_fixture(hass_enforce_type_hints, linter) -> BaseChecker: @pytest.fixture(name="hass_imports", scope="session") def hass_imports_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - loader = SourceFileLoader( - "hass_imports", - str(BASE_PATH.joinpath("pylint/plugins/hass_imports.py")), + module_name = "hass_imports" + spec = spec_from_file_location( + module_name, str(BASE_PATH.joinpath("pylint/plugins/hass_imports.py")) ) - return loader.load_module(None) + assert spec and spec.loader + + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module @pytest.fixture(name="imports_checker") From b815ea1332667e10c5af3c4387bcfa41961d590f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 Sep 2023 11:54:18 +0200 Subject: [PATCH 197/984] Revert "Remove imap_email_content integration" (#99713) --- .coveragerc | 1 + homeassistant/components/imap/config_flow.py | 30 +- .../components/imap_email_content/__init__.py | 17 + .../components/imap_email_content/const.py | 13 + .../imap_email_content/manifest.json | 8 + .../components/imap_email_content/repairs.py | 173 ++++++++++ .../components/imap_email_content/sensor.py | 302 ++++++++++++++++++ .../imap_email_content/strings.json | 27 ++ homeassistant/generated/integrations.json | 6 + tests/components/imap/test_config_flow.py | 67 ++++ .../components/imap_email_content/__init__.py | 1 + .../imap_email_content/test_repairs.py | 296 +++++++++++++++++ .../imap_email_content/test_sensor.py | 253 +++++++++++++++ 13 files changed, 1193 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/imap_email_content/__init__.py create mode 100644 homeassistant/components/imap_email_content/const.py create mode 100644 homeassistant/components/imap_email_content/manifest.json create mode 100644 homeassistant/components/imap_email_content/repairs.py create mode 100644 homeassistant/components/imap_email_content/sensor.py create mode 100644 homeassistant/components/imap_email_content/strings.json create mode 100644 tests/components/imap_email_content/__init__.py create mode 100644 tests/components/imap_email_content/test_repairs.py create mode 100644 tests/components/imap_email_content/test_sensor.py diff --git a/.coveragerc b/.coveragerc index f2231ea31c2..d28878d8861 100644 --- a/.coveragerc +++ b/.coveragerc @@ -547,6 +547,7 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* + homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py homeassistant/components/insteon/climate.py diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 70594d5fd7c..4c4a2e2a35c 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,7 +10,13 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv @@ -126,6 +132,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: config_entries.ConfigEntry | None + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle the import from imap_email_content integration.""" + data = CONFIG_SCHEMA( + { + CONF_SERVER: user_input[CONF_SERVER], + CONF_PORT: user_input[CONF_PORT], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_FOLDER: user_input[CONF_FOLDER], + } + ) + self._async_abort_entries_match( + { + key: data[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) + } + ) + title = user_input[CONF_NAME] + if await validate_input(self.hass, data): + raise AbortFlow("cannot_connect") + return self.async_create_entry(title=title, data=data) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py new file mode 100644 index 00000000000..f2041b947df --- /dev/null +++ b/homeassistant/components/imap_email_content/__init__.py @@ -0,0 +1,17 @@ +"""The imap_email_content component.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +PLATFORMS = [Platform.SENSOR] + +CONFIG_SCHEMA = cv.deprecated(DOMAIN, raise_if_present=False) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up imap_email_content.""" + return True diff --git a/homeassistant/components/imap_email_content/const.py b/homeassistant/components/imap_email_content/const.py new file mode 100644 index 00000000000..5f1c653030e --- /dev/null +++ b/homeassistant/components/imap_email_content/const.py @@ -0,0 +1,13 @@ +"""Constants for the imap email content integration.""" + +DOMAIN = "imap_email_content" + +CONF_SERVER = "server" +CONF_SENDERS = "senders" +CONF_FOLDER = "folder" + +ATTR_FROM = "from" +ATTR_BODY = "body" +ATTR_SUBJECT = "subject" + +DEFAULT_PORT = 993 diff --git a/homeassistant/components/imap_email_content/manifest.json b/homeassistant/components/imap_email_content/manifest.json new file mode 100644 index 00000000000..b7d0589b83f --- /dev/null +++ b/homeassistant/components/imap_email_content/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "imap_email_content", + "name": "IMAP Email Content", + "codeowners": [], + "dependencies": ["imap"], + "documentation": "https://www.home-assistant.io/integrations/imap_email_content", + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py new file mode 100644 index 00000000000..f19b0499040 --- /dev/null +++ b/homeassistant/components/imap_email_content/repairs.py @@ -0,0 +1,173 @@ +"""Repair flow for imap email content integration.""" + +from typing import Any + +import voluptuous as vol +import yaml + +from homeassistant import data_entry_flow +from homeassistant.components.imap import DOMAIN as IMAP_DOMAIN +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_FOLDER, CONF_SENDERS, CONF_SERVER, DOMAIN + + +async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: + """Register an issue and suggest new config.""" + + name: str = config.get(CONF_NAME) or config[CONF_USERNAME] + + issue_id = ( + f"{name}_{config[CONF_USERNAME]}_{config[CONF_SERVER]}_{config[CONF_FOLDER]}" + ) + + if CONF_VALUE_TEMPLATE in config: + template: str = config[CONF_VALUE_TEMPLATE].template + template = template.replace("subject", 'trigger.event.data["subject"]') + template = template.replace("from", 'trigger.event.data["sender"]') + template = template.replace("date", 'trigger.event.data["date"]') + template = template.replace("body", 'trigger.event.data["text"]') + else: + template = '{{ trigger.event.data["subject"] }}' + + template_sensor_config: ConfigType = { + "template": [ + { + "trigger": [ + { + "id": "custom_event", + "platform": "event", + "event_type": "imap_content", + "event_data": {"sender": config[CONF_SENDERS][0]}, + } + ], + "sensor": [ + { + "state": template, + "name": name, + } + ], + } + ] + } + + data = { + CONF_SERVER: config[CONF_SERVER], + CONF_PORT: config[CONF_PORT], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_FOLDER: config[CONF_FOLDER], + } + data[CONF_VALUE_TEMPLATE] = template + data[CONF_NAME] = name + placeholders = {"yaml_example": yaml.dump(template_sensor_config)} + placeholders.update(data) + + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="migration", + translation_placeholders=placeholders, + data=data, + ) + + +class DeprecationRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, issue_id: str, config: ConfigType) -> None: + """Create flow.""" + self._name: str = config[CONF_NAME] + self._config: dict[str, Any] = config + self._issue_id = issue_id + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_start() + + @callback + def _async_get_placeholders(self) -> dict[str, str] | None: + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return description_placeholders + + async def async_step_start( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Wait for the user to start the config migration.""" + placeholders = self._async_get_placeholders() + if user_input is None: + return self.async_show_form( + step_id="start", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + placeholders = self._async_get_placeholders() + if user_input is not None: + user_input[CONF_NAME] = self._name + result = await self.hass.config_entries.flow.async_init( + IMAP_DOMAIN, context={"source": SOURCE_IMPORT}, data=self._config + ) + if result["type"] == FlowResultType.ABORT: + ir.async_delete_issue(self.hass, DOMAIN, self._issue_id) + ir.async_create_issue( + self.hass, + DOMAIN, + self._issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecation", + translation_placeholders=placeholders, + data=self._config, + learn_more_url="https://www.home-assistant.io/integrations/imap/#using-events", + ) + return self.async_abort(reason=result["reason"]) + return self.async_create_entry( + title="", + data={}, + ) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None], +) -> RepairsFlow: + """Create flow.""" + return DeprecationRepairFlow(issue_id, data) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py new file mode 100644 index 00000000000..1df207e2968 --- /dev/null +++ b/homeassistant/components/imap_email_content/sensor.py @@ -0,0 +1,302 @@ +"""Email sensor support.""" +from __future__ import annotations + +from collections import deque +import datetime +import email +import imaplib +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.const import ( + ATTR_DATE, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, + CONTENT_TYPE_TEXT_PLAIN, +) +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.ssl import client_context + +from .const import ( + ATTR_BODY, + ATTR_FROM, + ATTR_SUBJECT, + CONF_FOLDER, + CONF_SENDERS, + CONF_SERVER, + DEFAULT_PORT, +) +from .repairs import async_process_issue + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_SERVER): cv.string, + vol.Required(CONF_SENDERS): [cv.string], + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + } +) + + +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Email sensor platform.""" + reader = EmailReader( + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_SERVER], + config[CONF_PORT], + config[CONF_FOLDER], + config[CONF_VERIFY_SSL], + ) + + if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None: + value_template.hass = hass + sensor = EmailContentSensor( + hass, + reader, + config.get(CONF_NAME) or config[CONF_USERNAME], + config[CONF_SENDERS], + value_template, + ) + + hass.add_job(async_process_issue, hass, config) + + if sensor.connected: + add_entities([sensor], True) + + +class EmailReader: + """A class to read emails from an IMAP server.""" + + def __init__(self, user, password, server, port, folder, verify_ssl): + """Initialize the Email Reader.""" + self._user = user + self._password = password + self._server = server + self._port = port + self._folder = folder + self._verify_ssl = verify_ssl + self._last_id = None + self._last_message = None + self._unread_ids = deque([]) + self.connection = None + + @property + def last_id(self) -> int | None: + """Return last email uid that was processed.""" + return self._last_id + + @property + def last_unread_id(self) -> int | None: + """Return last email uid received.""" + # We assume the last id in the list is the last unread id + # We cannot know if that is the newest one, because it could arrive later + # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids + if self._unread_ids: + return int(self._unread_ids[-1]) + return self._last_id + + def connect(self): + """Login and setup the connection.""" + ssl_context = client_context() if self._verify_ssl else None + try: + self.connection = imaplib.IMAP4_SSL( + self._server, self._port, ssl_context=ssl_context + ) + self.connection.login(self._user, self._password) + return True + except imaplib.IMAP4.error: + _LOGGER.error("Failed to login to %s", self._server) + return False + + def _fetch_message(self, message_uid): + """Get an email message from a message id.""" + _, message_data = self.connection.uid("fetch", message_uid, "(RFC822)") + + if message_data is None: + return None + if message_data[0] is None: + return None + raw_email = message_data[0][1] + email_message = email.message_from_bytes(raw_email) + return email_message + + def read_next(self): + """Read the next email from the email server.""" + try: + self.connection.select(self._folder, readonly=True) + + if self._last_id is None: + # search for today and yesterday + time_from = datetime.datetime.now() - datetime.timedelta(days=1) + search = f"SINCE {time_from:%d-%b-%Y}" + else: + search = f"UID {self._last_id}:*" + + _, data = self.connection.uid("search", None, search) + self._unread_ids = deque(data[0].split()) + while self._unread_ids: + message_uid = self._unread_ids.popleft() + if self._last_id is None or int(message_uid) > self._last_id: + self._last_id = int(message_uid) + self._last_message = self._fetch_message(message_uid) + return self._last_message + + except imaplib.IMAP4.error: + _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) + try: + self.connect() + _LOGGER.info( + "Reconnect to %s succeeded, trying last message", self._server + ) + if self._last_id is not None: + return self._fetch_message(str(self._last_id)) + except imaplib.IMAP4.error: + _LOGGER.error("Failed to reconnect") + + return None + + +class EmailContentSensor(SensorEntity): + """Representation of an EMail sensor.""" + + def __init__(self, hass, email_reader, name, allowed_senders, value_template): + """Initialize the sensor.""" + self.hass = hass + self._email_reader = email_reader + self._name = name + self._allowed_senders = [sender.upper() for sender in allowed_senders] + self._value_template = value_template + self._last_id = None + self._message = None + self._state_attributes = None + self.connected = self._email_reader.connect() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def native_value(self): + """Return the current email state.""" + return self._message + + @property + def extra_state_attributes(self): + """Return other state attributes for the message.""" + return self._state_attributes + + def render_template(self, email_message): + """Render the message template.""" + variables = { + ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), + ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), + ATTR_DATE: email_message["Date"], + ATTR_BODY: EmailContentSensor.get_msg_text(email_message), + } + return self._value_template.render(variables, parse_result=False) + + def sender_allowed(self, email_message): + """Check if the sender is in the allowed senders list.""" + return EmailContentSensor.get_msg_sender(email_message).upper() in ( + sender for sender in self._allowed_senders + ) + + @staticmethod + def get_msg_sender(email_message): + """Get the parsed message sender from the email.""" + return str(email.utils.parseaddr(email_message["From"])[1]) + + @staticmethod + def get_msg_subject(email_message): + """Decode the message subject.""" + decoded_header = email.header.decode_header(email_message["Subject"]) + header = email.header.make_header(decoded_header) + return str(header) + + @staticmethod + def get_msg_text(email_message): + """Get the message text from the email. + + Will look for text/plain or use text/html if not found. + """ + message_text = None + message_html = None + message_untyped_text = None + + for part in email_message.walk(): + if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: + if message_text is None: + message_text = part.get_payload() + elif part.get_content_type() == "text/html": + if message_html is None: + message_html = part.get_payload() + elif ( + part.get_content_type().startswith("text") + and message_untyped_text is None + ): + message_untyped_text = part.get_payload() + + if message_text is not None: + return message_text + + if message_html is not None: + return message_html + + if message_untyped_text is not None: + return message_untyped_text + + return email_message.get_payload() + + def update(self) -> None: + """Read emails and publish state change.""" + email_message = self._email_reader.read_next() + while ( + self._last_id is None or self._last_id != self._email_reader.last_unread_id + ): + if email_message is None: + self._message = None + self._state_attributes = {} + return + + self._last_id = self._email_reader.last_id + + if self.sender_allowed(email_message): + message = EmailContentSensor.get_msg_subject(email_message) + + if self._value_template is not None: + message = self.render_template(email_message) + + self._message = message + self._state_attributes = { + ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), + ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), + ATTR_DATE: email_message["Date"], + ATTR_BODY: EmailContentSensor.get_msg_text(email_message), + } + + if self._last_id == self._email_reader.last_unread_id: + break + email_message = self._email_reader.read_next() diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json new file mode 100644 index 00000000000..b7b987b1212 --- /dev/null +++ b/homeassistant/components/imap_email_content/strings.json @@ -0,0 +1,27 @@ +{ + "issues": { + "deprecation": { + "title": "The IMAP email content integration is deprecated", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap). To set up a sensor for the IMAP email content, set up a template sensor with the config:\n\n```yaml\n{yaml_example}```\n\nPlease remove the deprecated `imap_email_plaform` sensor configuration from your `configuration.yaml`.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\nYou can skip this part if you have already set up a template sensor." + }, + "migration": { + "title": "The IMAP email content integration needs attention", + "fix_flow": { + "step": { + "start": { + "title": "Migrate your IMAP email configuration", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration can be migrated automatically to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap), this will enable using a custom `imap` event trigger. To set up a sensor that has an IMAP content state, a template sensor can be used. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml` after migration.\n\nSubmit to start migration of your IMAP server configuration to the `imap` integration." + }, + "confirm": { + "title": "Your IMAP server settings will be migrated", + "description": "In this step an `imap` config entry will be set up with the following configuration:\n\n```text\nServer\t{server}\nPort\t{port}\nUsername\t{username}\nPassword\t*****\nFolder\t{folder}\n```\n\nSee also: (https://www.home-assistant.io/integrations/imap/)\n\nFitering configuration on allowed `sender` is part of the template sensor config that can copied and placed in your `configuration.yaml.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\n```yaml\n{yaml_example}```\nDo not forget to cleanup the your `configuration.yaml` after migration.\n\nSubmit to migrate your IMAP server configuration to an `imap` configuration entry." + } + }, + "abort": { + "already_configured": "The IMAP server config was already migrated to the imap integration. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml`.", + "cannot_connect": "Migration failed. Failed to connect to the IMAP server. Perform a manual migration." + } + } + } + } +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 39c7a82ce55..379dd112672 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2581,6 +2581,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "imap_email_content": { + "name": "IMAP Email Content", + "integration_type": "hub", + "config_flow": false, + "iot_class": "cloud_push" + }, "incomfort": { "name": "Intergas InComfort/Intouch Lan2RF gateway", "integration_type": "hub", diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index d36cffbce06..efb505cda77 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -469,6 +469,73 @@ async def test_advanced_options_form( assert assert_result == data_entry_flow.FlowResultType.FORM +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client, patch( + "homeassistant.components.imap.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "name": "IMAP", + "username": "email@email.com", + "password": "password", + "server": "imap.server.com", + "port": 993, + "folder": "INBOX", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "IMAP" + assert result2["data"] == { + "username": "email@email.com", + "password": "password", + "server": "imap.server.com", + "port": 993, + "charset": "utf-8", + "folder": "INBOX", + "search": "UnSeen UnDeleted", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_connection_error(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + with patch( + "homeassistant.components.imap.config_flow.connect_to_server", + side_effect=AioImapException("Unexpected error"), + ), patch( + "homeassistant.components.imap.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "name": "IMAP", + "username": "email@email.com", + "password": "password", + "server": "imap.server.com", + "port": 993, + "folder": "INBOX", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + @pytest.mark.parametrize("cipher_list", ["python_default", "modern", "intermediate"]) @pytest.mark.parametrize("verify_ssl", [False, True]) async def test_config_flow_with_cipherlist_and_ssl_verify( diff --git a/tests/components/imap_email_content/__init__.py b/tests/components/imap_email_content/__init__.py new file mode 100644 index 00000000000..2c7e5692366 --- /dev/null +++ b/tests/components/imap_email_content/__init__.py @@ -0,0 +1 @@ +"""Tests for the imap_email_content component.""" diff --git a/tests/components/imap_email_content/test_repairs.py b/tests/components/imap_email_content/test_repairs.py new file mode 100644 index 00000000000..6323dcde377 --- /dev/null +++ b/tests/components/imap_email_content/test_repairs.py @@ -0,0 +1,296 @@ +"""Test repairs for imap_email_content.""" + +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture +def mock_client() -> Generator[MagicMock, None, None]: + """Mock the imap client.""" + with patch( + "homeassistant.components.imap_email_content.sensor.EmailReader.read_next", + return_value=None, + ), patch("imaplib.IMAP4_SSL") as mock_imap_client: + yield mock_imap_client + + +CONFIG = { + "platform": "imap_email_content", + "name": "Notifications", + "server": "imap.example.com", + "port": 993, + "username": "john.doe@example.com", + "password": "**SECRET**", + "folder": "INBOX.Notifications", + "value_template": "{{ body }}", + "senders": ["company@example.com"], +} +DESCRIPTION_PLACEHOLDERS = { + "yaml_example": "" + "template:\n" + "- sensor:\n" + " - name: Notifications\n" + " state: '{{ trigger.event.data[\"text\"] }}'\n" + " trigger:\n - event_data:\n" + " sender: company@example.com\n" + " event_type: imap_content\n" + " id: custom_event\n" + " platform: event\n", + "server": "imap.example.com", + "port": 993, + "username": "john.doe@example.com", + "password": "**SECRET**", + "folder": "INBOX.Notifications", + "value_template": '{{ trigger.event.data["text"] }}', + "name": "Notifications", +} + +CONFIG_DEFAULT = { + "platform": "imap_email_content", + "name": "Notifications", + "server": "imap.example.com", + "port": 993, + "username": "john.doe@example.com", + "password": "**SECRET**", + "folder": "INBOX.Notifications", + "senders": ["company@example.com"], +} +DESCRIPTION_PLACEHOLDERS_DEFAULT = { + "yaml_example": "" + "template:\n" + "- sensor:\n" + " - name: Notifications\n" + " state: '{{ trigger.event.data[\"subject\"] }}'\n" + " trigger:\n - event_data:\n" + " sender: company@example.com\n" + " event_type: imap_content\n" + " id: custom_event\n" + " platform: event\n", + "server": "imap.example.com", + "port": 993, + "username": "john.doe@example.com", + "password": "**SECRET**", + "folder": "INBOX.Notifications", + "value_template": '{{ trigger.event.data["subject"] }}', + "name": "Notifications", +} + + +@pytest.mark.parametrize( + ("config", "description_placeholders"), + [ + (CONFIG, DESCRIPTION_PLACEHOLDERS), + (CONFIG_DEFAULT, DESCRIPTION_PLACEHOLDERS_DEFAULT), + ], + ids=["with_value_template", "default_subject"], +) +async def test_deprecation_repair_flow( + hass: HomeAssistant, + mock_client: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + config: str | None, + description_placeholders: str, +) -> None: + """Test the deprecation repair flow.""" + # setup config + await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.notifications") + assert state is not None + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["domain"] == "imap_email_content": + issue = i + assert issue is not None + assert ( + issue["issue_id"] + == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" + ) + assert issue["is_fixable"] + url = RepairsFlowIndexView.url + resp = await client.post( + url, json={"handler": "imap_email_content", "issue_id": issue["issue_id"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == description_placeholders + assert data["step_id"] == "start" + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == description_placeholders + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client, patch( + "homeassistant.components.imap.async_setup_entry", + return_value=True, + ): + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + resp = await client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + # Assert the issue is resolved + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + +@pytest.mark.parametrize( + ("config", "description_placeholders"), + [ + (CONFIG, DESCRIPTION_PLACEHOLDERS), + (CONFIG_DEFAULT, DESCRIPTION_PLACEHOLDERS_DEFAULT), + ], + ids=["with_value_template", "default_subject"], +) +async def test_repair_flow_where_entry_already_exists( + hass: HomeAssistant, + mock_client: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + config: str | None, + description_placeholders: str, +) -> None: + """Test the deprecation repair flow and an entry already exists.""" + + await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() + state = hass.states.get("sensor.notifications") + assert state is not None + + existing_imap_entry_config = { + "username": "john.doe@example.com", + "password": "password", + "server": "imap.example.com", + "port": 993, + "charset": "utf-8", + "folder": "INBOX.Notifications", + "search": "UnSeen UnDeleted", + } + + with patch("homeassistant.components.imap.async_setup_entry", return_value=True): + imap_entry = MockConfigEntry(domain="imap", data=existing_imap_entry_config) + imap_entry.add_to_hass(hass) + await hass.config_entries.async_setup(imap_entry.entry_id) + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["domain"] == "imap_email_content": + issue = i + assert issue is not None + assert ( + issue["issue_id"] + == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" + ) + assert issue["is_fixable"] + assert issue["translation_key"] == "migration" + + url = RepairsFlowIndexView.url + resp = await client.post( + url, json={"handler": "imap_email_content", "issue_id": issue["issue_id"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == description_placeholders + assert data["step_id"] == "start" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == description_placeholders + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client, patch( + "homeassistant.components.imap.async_setup_entry", + return_value=True, + ): + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + resp = await client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "abort" + assert data["reason"] == "already_configured" + + # We should now have a non_fixable issue left since there is still + # a config in configuration.yaml + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["domain"] == "imap_email_content": + issue = i + assert issue is not None + assert ( + issue["issue_id"] + == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" + ) + assert not issue["is_fixable"] + assert issue["translation_key"] == "deprecation" diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py new file mode 100644 index 00000000000..3e8a6c1e282 --- /dev/null +++ b/tests/components/imap_email_content/test_sensor.py @@ -0,0 +1,253 @@ +"""The tests for the IMAP email content sensor platform.""" +from collections import deque +import datetime +import email +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from homeassistant.components.imap_email_content import sensor as imap_email_content +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.template import Template +from homeassistant.setup import async_setup_component + + +class FakeEMailReader: + """A test class for sending test emails.""" + + def __init__(self, messages) -> None: + """Set up the fake email reader.""" + self._messages = messages + self.last_id = 0 + self.last_unread_id = len(messages) + + def add_test_message(self, message): + """Add a new message.""" + self.last_unread_id += 1 + self._messages.append(message) + + def connect(self): + """Stay always Connected.""" + return True + + def read_next(self): + """Get the next email.""" + if len(self._messages) == 0: + return None + self.last_id += 1 + return self._messages.popleft() + + +async def test_integration_setup_(hass: HomeAssistant) -> None: + """Test the integration component setup is successful.""" + assert await async_setup_component(hass, "imap_email_content", {}) + + +async def test_allowed_sender(hass: HomeAssistant) -> None: + """Test emails from allowed sender.""" + test_message = email.message.Message() + test_message["From"] = "sender@test.com" + test_message["Subject"] = "Test" + test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) + test_message.set_payload("Test Message") + + sensor = imap_email_content.EmailContentSensor( + hass, + FakeEMailReader(deque([test_message])), + "test_emails_sensor", + ["sender@test.com"], + None, + ) + + sensor.entity_id = "sensor.emailtest" + sensor.async_schedule_update_ha_state(True) + await hass.async_block_till_done() + assert sensor.state == "Test" + assert sensor.extra_state_attributes["body"] == "Test Message" + assert sensor.extra_state_attributes["from"] == "sender@test.com" + assert sensor.extra_state_attributes["subject"] == "Test" + assert ( + datetime.datetime(2016, 1, 1, 12, 44, 57) + == sensor.extra_state_attributes["date"] + ) + + +async def test_multi_part_with_text(hass: HomeAssistant) -> None: + """Test multi part emails.""" + msg = MIMEMultipart("alternative") + msg["Subject"] = "Link" + msg["From"] = "sender@test.com" + + text = "Test Message" + html = "Test Message" + + textPart = MIMEText(text, "plain") + htmlPart = MIMEText(html, "html") + + msg.attach(textPart) + msg.attach(htmlPart) + + sensor = imap_email_content.EmailContentSensor( + hass, + FakeEMailReader(deque([msg])), + "test_emails_sensor", + ["sender@test.com"], + None, + ) + + sensor.entity_id = "sensor.emailtest" + sensor.async_schedule_update_ha_state(True) + await hass.async_block_till_done() + assert sensor.state == "Link" + assert sensor.extra_state_attributes["body"] == "Test Message" + + +async def test_multi_part_only_html(hass: HomeAssistant) -> None: + """Test multi part emails with only HTML.""" + msg = MIMEMultipart("alternative") + msg["Subject"] = "Link" + msg["From"] = "sender@test.com" + + html = "Test Message" + + htmlPart = MIMEText(html, "html") + + msg.attach(htmlPart) + + sensor = imap_email_content.EmailContentSensor( + hass, + FakeEMailReader(deque([msg])), + "test_emails_sensor", + ["sender@test.com"], + None, + ) + + sensor.entity_id = "sensor.emailtest" + sensor.async_schedule_update_ha_state(True) + await hass.async_block_till_done() + assert sensor.state == "Link" + assert ( + sensor.extra_state_attributes["body"] + == "Test Message" + ) + + +async def test_multi_part_only_other_text(hass: HomeAssistant) -> None: + """Test multi part emails with only other text.""" + msg = MIMEMultipart("alternative") + msg["Subject"] = "Link" + msg["From"] = "sender@test.com" + + other = "Test Message" + + htmlPart = MIMEText(other, "other") + + msg.attach(htmlPart) + + sensor = imap_email_content.EmailContentSensor( + hass, + FakeEMailReader(deque([msg])), + "test_emails_sensor", + ["sender@test.com"], + None, + ) + + sensor.entity_id = "sensor.emailtest" + sensor.async_schedule_update_ha_state(True) + await hass.async_block_till_done() + assert sensor.state == "Link" + assert sensor.extra_state_attributes["body"] == "Test Message" + + +async def test_multiple_emails(hass: HomeAssistant) -> None: + """Test multiple emails, discarding stale states.""" + states = [] + + test_message1 = email.message.Message() + test_message1["From"] = "sender@test.com" + test_message1["Subject"] = "Test" + test_message1["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) + test_message1.set_payload("Test Message") + + test_message2 = email.message.Message() + test_message2["From"] = "sender@test.com" + test_message2["Subject"] = "Test 2" + test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58) + test_message2.set_payload("Test Message 2") + + test_message3 = email.message.Message() + test_message3["From"] = "sender@test.com" + test_message3["Subject"] = "Test 3" + test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1) + test_message3.set_payload("Test Message 2") + + def state_changed_listener(entity_id, from_s, to_s): + states.append(to_s) + + async_track_state_change(hass, ["sensor.emailtest"], state_changed_listener) + + sensor = imap_email_content.EmailContentSensor( + hass, + FakeEMailReader(deque([test_message1, test_message2])), + "test_emails_sensor", + ["sender@test.com"], + None, + ) + + sensor.entity_id = "sensor.emailtest" + + sensor.async_schedule_update_ha_state(True) + await hass.async_block_till_done() + # Fake a new received message + sensor._email_reader.add_test_message(test_message3) + sensor.async_schedule_update_ha_state(True) + await hass.async_block_till_done() + + assert states[0].state == "Test 2" + assert states[1].state == "Test 3" + + assert sensor.extra_state_attributes["body"] == "Test Message 2" + + +async def test_sender_not_allowed(hass: HomeAssistant) -> None: + """Test not whitelisted emails.""" + test_message = email.message.Message() + test_message["From"] = "sender@test.com" + test_message["Subject"] = "Test" + test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) + test_message.set_payload("Test Message") + + sensor = imap_email_content.EmailContentSensor( + hass, + FakeEMailReader(deque([test_message])), + "test_emails_sensor", + ["other@test.com"], + None, + ) + + sensor.entity_id = "sensor.emailtest" + sensor.async_schedule_update_ha_state(True) + await hass.async_block_till_done() + assert sensor.state is None + + +async def test_template(hass: HomeAssistant) -> None: + """Test value template.""" + test_message = email.message.Message() + test_message["From"] = "sender@test.com" + test_message["Subject"] = "Test" + test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) + test_message.set_payload("Test Message") + + sensor = imap_email_content.EmailContentSensor( + hass, + FakeEMailReader(deque([test_message])), + "test_emails_sensor", + ["sender@test.com"], + Template("{{ subject }} from {{ from }} with message {{ body }}", hass), + ) + + sensor.entity_id = "sensor.emailtest" + sensor.async_schedule_update_ha_state(True) + await hass.async_block_till_done() + assert sensor.state == "Test from sender@test.com with message Test Message" From 397952ceeaa6df91d989e4ebf6a523f023eeea09 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 Sep 2023 12:45:46 +0200 Subject: [PATCH 198/984] Postpone Imap_email_content removal (#99721) --- homeassistant/components/imap_email_content/repairs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py index f19b0499040..8fe05f80c08 100644 --- a/homeassistant/components/imap_email_content/repairs.py +++ b/homeassistant/components/imap_email_content/repairs.py @@ -79,7 +79,7 @@ async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: hass, DOMAIN, issue_id, - breaks_in_ha_version="2023.10.0", + breaks_in_ha_version="2023.11.0", is_fixable=True, severity=ir.IssueSeverity.WARNING, translation_key="migration", @@ -143,7 +143,7 @@ class DeprecationRepairFlow(RepairsFlow): self.hass, DOMAIN, self._issue_id, - breaks_in_ha_version="2023.10.0", + breaks_in_ha_version="2023.11.0", is_fixable=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecation", From 0037385336580d1fa4f09d9676c3279083f36837 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 6 Sep 2023 14:46:24 +0200 Subject: [PATCH 199/984] Reolink onvif not supported fix (#99714) * only subscibe to ONVIF if supported * Catch NotSupportedError when ONVIF is not supported * fix styling --- homeassistant/components/reolink/host.py | 47 +++++++++++++++++------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index a679cb34f4b..a43dbce9a7c 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -10,7 +10,7 @@ import aiohttp from aiohttp.web import Request from reolink_aio.api import Host from reolink_aio.enums import SubType -from reolink_aio.exceptions import ReolinkError, SubscriptionError +from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components import webhook from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -61,6 +61,7 @@ class ReolinkHost: ) self.webhook_id: str | None = None + self._onvif_supported: bool = True self._base_url: str = "" self._webhook_url: str = "" self._webhook_reachable: bool = False @@ -96,6 +97,8 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + self._onvif_supported = self._api.supported(None, "ONVIF") + enable_rtsp = None enable_onvif = None enable_rtmp = None @@ -106,7 +109,7 @@ class ReolinkHost: ) enable_rtsp = True - if not self._api.onvif_enabled: + if not self._api.onvif_enabled and self._onvif_supported: _LOGGER.debug( "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name ) @@ -154,21 +157,34 @@ class ReolinkHost: self._unique_id = format_mac(self._api.mac_address) - await self.subscribe() - - if self._api.supported(None, "initial_ONVIF_state"): + if self._onvif_supported: + try: + await self.subscribe() + except NotSupportedError: + self._onvif_supported = False + self.unregister_webhook() + await self._api.unsubscribe() + else: + if self._api.supported(None, "initial_ONVIF_state"): + _LOGGER.debug( + "Waiting for initial ONVIF state on webhook '%s'", + self._webhook_url, + ) + else: + _LOGGER.debug( + "Camera model %s most likely does not push its initial state" + " upon ONVIF subscription, do not check", + self._api.model, + ) + self._cancel_onvif_check = async_call_later( + self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif + ) + if not self._onvif_supported: _LOGGER.debug( - "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url - ) - else: - _LOGGER.debug( - "Camera model %s most likely does not push its initial state" - " upon ONVIF subscription, do not check", + "Camera model %s does not support ONVIF, using fast polling instead", self._api.model, ) - self._cancel_onvif_check = async_call_later( - self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif - ) + await self._async_poll_all_motion() if self._api.sw_version_update_required: ir.async_create_issue( @@ -365,6 +381,9 @@ class ReolinkHost: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" + if not self._onvif_supported: + return + try: await self._renew(SubType.push) if self._long_poll_task is not None: From 9700888df168ad017d544982198e3da7b79bb9e5 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Sep 2023 15:00:26 +0200 Subject: [PATCH 200/984] Update frontend to 20230906.1 (#99733) --- 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 9e0bd3e5de9..50c557eae89 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==20230906.0"] + "requirements": ["home-assistant-frontend==20230906.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 810f6d093bf..e0d75c1ec20 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230906.0 +home-assistant-frontend==20230906.1 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 04eddfd1de3..2e4499d5b3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230906.0 +home-assistant-frontend==20230906.1 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75dd6db70f5..7a1b0383431 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230906.0 +home-assistant-frontend==20230906.1 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From bb765449eb32802c4db4396d8c79fec26bc2e418 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 6 Sep 2023 09:03:54 -0400 Subject: [PATCH 201/984] Add binary_sensor to Schlage (#99637) * Add binary_sensor to Schlage * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/schlage/__init__.py | 7 +- .../components/schlage/binary_sensor.py | 92 +++++++++++++++++++ homeassistant/components/schlage/strings.json | 5 + tests/components/schlage/conftest.py | 1 + .../components/schlage/test_binary_sensor.py | 53 +++++++++++ 5 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/schlage/binary_sensor.py create mode 100644 tests/components/schlage/test_binary_sensor.py diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index cf95e190e88..feaa95864d5 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -11,7 +11,12 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN, LOGGER from .coordinator import SchlageDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py new file mode 100644 index 00000000000..749a961a53b --- /dev/null +++ b/homeassistant/components/schlage/binary_sensor.py @@ -0,0 +1,92 @@ +"""Platform for Schlage binary_sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +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 .coordinator import LockData, SchlageDataUpdateCoordinator +from .entity import SchlageEntity + + +@dataclass +class SchlageBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + # NOTE: This has to be a mixin because these are required keys. + # BinarySensorEntityDescription has attributes with default values, + # which means we can't inherit from it because you haven't have + # non-default arguments follow default arguments in an initializer. + + value_fn: Callable[[LockData], bool] + + +@dataclass +class SchlageBinarySensorEntityDescription( + BinarySensorEntityDescription, SchlageBinarySensorEntityDescriptionMixin +): + """Entity description for a Schlage binary_sensor.""" + + +_DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = ( + SchlageBinarySensorEntityDescription( + key="keypad_disabled", + translation_key="keypad_disabled", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.lock.keypad_disabled(data.logs), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensors based on a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + for device_id in coordinator.data.locks: + for description in _DESCRIPTIONS: + entities.append( + SchlageBinarySensor( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + ) + async_add_entities(entities) + + +class SchlageBinarySensor(SchlageEntity, BinarySensorEntity): + """Schlage binary_sensor entity.""" + + entity_description: SchlageBinarySensorEntityDescription + + def __init__( + self, + coordinator: SchlageDataUpdateCoordinator, + description: SchlageBinarySensorEntityDescription, + device_id: str, + ) -> None: + """Initialize a SchlageBinarySensor.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{self.entity_description.key}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary_sensor is on.""" + return self.entity_description.value_fn(self._lock_data) diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index f3612bb96b8..076ed97e298 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -17,6 +17,11 @@ } }, "entity": { + "binary_sensor": { + "keypad_disabled": { + "name": "Keypad disabled" + } + }, "switch": { "beeper": { "name": "Keypress Beep" diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 0078e6a5553..7b610a6b4da 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -85,4 +85,5 @@ def mock_lock(): ) mock_lock.logs.return_value = [] mock_lock.last_changed_by.return_value = "thumbturn" + mock_lock.keypad_disabled.return_value = False return mock_lock diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py new file mode 100644 index 00000000000..4673f263c8c --- /dev/null +++ b/tests/components/schlage/test_binary_sensor.py @@ -0,0 +1,53 @@ +"""Test Schlage binary_sensor.""" + +from datetime import timedelta +from unittest.mock import Mock + +from pyschlage.exceptions import UnknownError + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + + +async def test_keypad_disabled_binary_sensor( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test the keypad_disabled binary_sensor.""" + mock_lock.keypad_disabled.reset_mock() + mock_lock.keypad_disabled.return_value = True + + # Make the coordinator refresh data. + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == "on" + assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + + mock_lock.keypad_disabled.assert_called_once_with([]) + + +async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test the keypad_disabled binary_sensor.""" + mock_lock.keypad_disabled.reset_mock() + mock_lock.keypad_disabled.return_value = True + mock_lock.logs.reset_mock() + mock_lock.logs.side_effect = UnknownError("Cannot load logs") + + # Make the coordinator refresh data. + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == "on" + assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + + mock_lock.keypad_disabled.assert_called_once_with([]) From 97710dc5b73ef053ee78f748c15bb0aebcc11ee5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 15:59:03 +0200 Subject: [PATCH 202/984] Correct state attributes in group helper preview (#99723) --- homeassistant/components/group/config_flow.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 9eb973b9609..93160b0db5b 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -361,6 +361,7 @@ def ws_start_preview( msg: dict[str, Any], ) -> None: """Generate a preview.""" + entity_registry_entry: er.RegistryEntry | None = None if msg["flow_type"] == "config_flow": flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) group_type = flow_status["step_id"] @@ -370,12 +371,17 @@ def ws_start_preview( name = validated["name"] else: flow_status = hass.config_entries.options.async_get(msg["flow_id"]) - config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + config_entry_id = flow_status["handler"] + config_entry = hass.config_entries.async_get_entry(config_entry_id) if not config_entry: raise HomeAssistantError group_type = config_entry.options["group_type"] name = config_entry.options["name"] validated = PREVIEW_OPTIONS_SCHEMA[group_type](msg["user_input"]) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + if entries: + entity_registry_entry = entries[0] @callback def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: @@ -388,6 +394,7 @@ def ws_start_preview( preview_entity = CREATE_PREVIEW_ENTITY[group_type](name, validated) preview_entity.hass = hass + preview_entity.registry_entry = entity_registry_entry connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( From c376447ccdd1068e828b146b139854940be8b09a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 15:59:30 +0200 Subject: [PATCH 203/984] Don't allow changing device class in template binary sensor options (#99720) --- homeassistant/components/template/config_flow.py | 8 ++++---- homeassistant/components/template/strings.json | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 093cbf14098..15be2c52d91 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -40,11 +40,11 @@ from .template_entity import TemplateEntity NONE_SENTINEL = "none" -def generate_schema(domain: str) -> dict[vol.Marker, Any]: +def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: """Generate schema.""" schema: dict[vol.Marker, Any] = {} - if domain == Platform.BINARY_SENSOR: + if domain == Platform.BINARY_SENSOR and flow_type == "config": schema = { vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( @@ -124,7 +124,7 @@ def options_schema(domain: str) -> vol.Schema: """Generate options schema.""" return vol.Schema( {vol.Required(CONF_STATE): selector.TemplateSelector()} - | generate_schema(domain), + | generate_schema(domain, "option"), ) @@ -135,7 +135,7 @@ def config_schema(domain: str) -> vol.Schema: vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_STATE): selector.TemplateSelector(), } - | generate_schema(domain), + | generate_schema(domain, "config"), ) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 7e5e56a26d6..a0ee31126cd 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -33,7 +33,6 @@ "step": { "binary_sensor": { "data": { - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", "state": "[%key:component::template::config::step::sensor::data::state%]" }, "title": "[%key:component::template::config::step::binary_sensor::title%]" From e1ea53e72fbcedfbb31af825a525610bc0464853 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 16:06:33 +0200 Subject: [PATCH 204/984] Correct state attributes in template helper preview (#99722) --- homeassistant/components/template/config_flow.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 15be2c52d91..c361b4c42cc 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import selector +from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -328,6 +328,7 @@ def ws_start_preview( return errors + entity_registry_entry: er.RegistryEntry | None = None if msg["flow_type"] == "config_flow": flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) template_type = flow_status["step_id"] @@ -342,6 +343,12 @@ def ws_start_preview( template_type = config_entry.options["template_type"] name = config_entry.options["name"] schema = cast(vol.Schema, OPTIONS_FLOW[template_type].schema) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, flow_status["handler"] + ) + if entries: + entity_registry_entry = entries[0] errors = _validate(schema, template_type, msg["user_input"]) @@ -382,6 +389,7 @@ def ws_start_preview( _strip_sentinel(msg["user_input"]) preview_entity = CREATE_PREVIEW_ENTITY[template_type](hass, name, msg["user_input"]) preview_entity.hass = hass + preview_entity.registry_entry = entity_registry_entry connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( From c9a6ea94a7db9a45826cf5e7091f852a86177c11 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 16:07:05 +0200 Subject: [PATCH 205/984] Send template render errors to template helper preview (#99716) --- .../components/template/template_entity.py | 23 +-- homeassistant/helpers/event.py | 13 +- tests/components/template/test_config_flow.py | 173 +++++++++++++++++- 3 files changed, 190 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 2ce42083117..8c3554c067e 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -15,13 +15,11 @@ from homeassistant.const import ( CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, - EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, ) from homeassistant.core import ( CALLBACK_TYPE, Context, - CoreState, HomeAssistant, State, callback, @@ -38,6 +36,7 @@ from homeassistant.helpers.event import ( async_track_template_result, ) from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.start import async_at_start from homeassistant.helpers.template import ( Template, TemplateStateFromEntityId, @@ -442,7 +441,11 @@ class TemplateEntity(Entity): ) @callback - def _async_template_startup(self, *_: Any) -> None: + def _async_template_startup( + self, + _hass: HomeAssistant | None, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: template_var_tups: list[TrackTemplate] = [] has_availability_template = False @@ -467,6 +470,7 @@ class TemplateEntity(Entity): self.hass, template_var_tups, self._handle_results, + log_fn=log_fn, has_super_template=has_availability_template, ) self.async_on_remove(result_info.async_remove) @@ -515,10 +519,13 @@ class TemplateEntity(Entity): ) -> CALLBACK_TYPE: """Render a preview.""" + def log_template_error(level: int, msg: str) -> None: + preview_callback(None, None, None, msg) + self._preview_callback = preview_callback self._async_setup_templates() try: - self._async_template_startup() + self._async_template_startup(None, log_template_error) except Exception as err: # pylint: disable=broad-exception-caught preview_callback(None, None, None, str(err)) return self._call_on_remove_callbacks @@ -527,13 +534,7 @@ class TemplateEntity(Entity): """Run when entity about to be added to hass.""" self._async_setup_templates() - if self.hass.state == CoreState.running: - self._async_template_startup() - return - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_template_startup - ) + async_at_start(self.hass, self._async_template_startup) async def async_update(self) -> None: """Call for forced update.""" diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 22e274a7d0f..1f74de497e2 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -957,11 +957,14 @@ class TrackTemplateResultInfo: if info.exception: if raise_on_template_error: raise info.exception - _LOGGER.error( - "Error while processing template: %s", - track_template_.template, - exc_info=info.exception, - ) + if not log_fn: + _LOGGER.error( + "Error while processing template: %s", + track_template_.template, + exc_info=info.exception, + ) + else: + log_fn(logging.ERROR, str(info.exception)) self._track_state_changes = async_track_state_change_filtered( self.hass, _render_infos_to_track_states(self._info.values()), self._refresh diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index b8634b68b1c..f4cfe90b9f0 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -272,12 +272,12 @@ async def test_options( ), ( "sensor", - "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + "{{ float(states('sensor.one'), default='') + float(states('sensor.two'), default='') }}", {}, {"one": "30.0", "two": "20.0"}, - ["unavailable", "50.0"], + ["", "50.0"], [{}, {}], - [["one"], ["one", "two"]], + [["one", "two"], ["one", "two"]], ), ), ) @@ -470,6 +470,173 @@ async def test_config_flow_preview_bad_input( } +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "input_states", + "template_states", + "error_events", + ), + [ + ( + "sensor", + "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + {"one": "30.0", "two": "20.0"}, + ["unavailable", "50.0"], + [ + ( + "ValueError: Template error: float got invalid input 'unknown' " + "when rendering template '{{ float(states('sensor.one')) + " + "float(states('sensor.two')) }}' but no default was specified" + ) + ], + ), + ], +) +async def test_config_flow_preview_template_startup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + input_states: dict[str, str], + template_states: list[str], + error_events: list[str], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template}, + } + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + + for error_event in error_events: + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == {"error": error_event} + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[0] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[input_entity], {} + ) + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[1] + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "input_states", + "template_states", + "error_events", + ), + [ + ( + "sensor", + "{{ float(states('sensor.one')) > 30 and undefined_function() }}", + [{"one": "30.0", "two": "20.0"}, {"one": "35.0", "two": "20.0"}], + ["False", "unavailable"], + ["'undefined_function' is undefined"], + ), + ], +) +async def test_config_flow_preview_template_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + input_states: list[dict[str, str]], + template_states: list[str], + error_events: list[str], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[0][input_entity], {} + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template}, + } + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[0] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[1][input_entity], {} + ) + + for error_event in error_events: + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == {"error": error_event} + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[1] + + @pytest.mark.parametrize( ( "template_type", From 0b95e4ac17db194f347e4607097be43c02b2f02b Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 6 Sep 2023 10:51:27 -0400 Subject: [PATCH 206/984] Fix the Hydrawise status sensor (#99271) --- homeassistant/components/hydrawise/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 63fe28cd400..9298e605791 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -90,7 +90,7 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """Get the latest data and updates the state.""" LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) if self.entity_description.key == "status": - self._attr_is_on = self.coordinator.api.status == "All good!" + 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" From 5d5466080264dab495f5c83731db6049760d018b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 16:53:41 +0200 Subject: [PATCH 207/984] Fix asyncio.wait typing (#99726) --- .../components/bluetooth_tracker/device_tracker.py | 3 +-- homeassistant/core.py | 13 ++++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 4bfbe72d8b5..6fecc428c10 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable from datetime import datetime, timedelta import logging from typing import Final @@ -152,7 +151,7 @@ async def async_setup_scanner( async def perform_bluetooth_update() -> None: """Discover Bluetooth devices and update status.""" _LOGGER.debug("Performing Bluetooth devices discovery and update") - tasks: list[Awaitable[None]] = [] + tasks: list[asyncio.Task[None]] = [] try: if track_new: diff --git a/homeassistant/core.py b/homeassistant/core.py index 3648fca99f7..2ffe51a4f3a 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -6,14 +6,7 @@ of entities and react to changes. from __future__ import annotations import asyncio -from collections.abc import ( - Awaitable, - Callable, - Collection, - Coroutine, - Iterable, - Mapping, -) +from collections.abc import Callable, Collection, Coroutine, Iterable, Mapping import concurrent.futures from contextlib import suppress import datetime @@ -714,7 +707,9 @@ class HomeAssistant: for task in tasks: _LOGGER.debug("Waiting for task: %s", task) - async def _await_and_log_pending(self, pending: Collection[Awaitable[Any]]) -> None: + async def _await_and_log_pending( + self, pending: Collection[asyncio.Future[Any]] + ) -> None: """Await and log tasks that take a long time.""" wait_time = 0 while pending: From d8035ddf474090258fc2d1785e38ef0726e2beb2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 16:57:13 +0200 Subject: [PATCH 208/984] Fix tradfri asyncio.wait (#99730) --- homeassistant/components/tradfri/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 2a3052c1f7b..a383cc2bbee 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -122,7 +122,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if same_hub_entries: await asyncio.wait( [ - self.hass.config_entries.async_remove(entry_id) + asyncio.create_task(self.hass.config_entries.async_remove(entry_id)) for entry_id in same_hub_entries ] ) From 2628a86864a517a24f1c8f8060dc5d9d238c15db Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 16:58:57 +0200 Subject: [PATCH 209/984] Update pre-commit to 3.4.0 (#99737) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 89db04a5db8..35d80233c3d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.3.0 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 -pre-commit==3.3.3 +pre-commit==3.4.0 pydantic==1.10.12 pylint==2.17.4 pylint-per-file-ignores==1.2.1 From ab3bc1b74b98e488653a4e9a435bfbf05c4080ac Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 17:00:16 +0200 Subject: [PATCH 210/984] Improve blink config_flow typing (#99579) --- homeassistant/components/blink/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 445a84f838c..d3b2878b522 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -59,7 +59,7 @@ def validate_input(auth: Auth) -> None: raise Require2FA -def _send_blink_2fa_pin(auth: Auth, pin: str) -> bool: +def _send_blink_2fa_pin(auth: Auth, pin: str | None) -> bool: """Send 2FA pin to blink servers.""" blink = Blink() blink.auth = auth @@ -122,8 +122,9 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle 2FA step.""" errors = {} if user_input is not None: - pin = user_input.get(CONF_PIN) + pin: str | None = user_input.get(CONF_PIN) try: + assert self.auth valid_token = await self.hass.async_add_executor_job( _send_blink_2fa_pin, self.auth, pin ) From eab76fc6212b7419de5a59de31c0021baa78e43c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 17:16:40 +0200 Subject: [PATCH 211/984] Revert "Bump pyoverkiz to 1.10.1 (#97916)" (#99742) --- 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 8cf029adb54..d88996c7e02 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.10.1"], + "requirements": ["pyoverkiz==1.9.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 2e4499d5b3f..f038ef9d247 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1918,7 +1918,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.10.1 +pyoverkiz==1.9.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a1b0383431..73af7791cd9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1428,7 +1428,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.10.1 +pyoverkiz==1.9.0 # homeassistant.components.openweathermap pyowm==3.2.0 From 8bfdc5d3d9afbfc450e67d062c10bcfec1d090f5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 17:37:11 +0200 Subject: [PATCH 212/984] Update pytest-aiohttp to 1.0.5 (#99744) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 35d80233c3d..4095d6732c9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -18,7 +18,7 @@ pylint==2.17.4 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 -pytest-aiohttp==1.0.4 +pytest-aiohttp==1.0.5 pytest-cov==4.1.0 pytest-freezer==0.4.8 pytest-socket==0.6.0 From f24c4ceab6b24d268f00c95537053374d31f9f08 Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 6 Sep 2023 08:55:41 -0700 Subject: [PATCH 213/984] Enable strict typing for Climate component (#99301) Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + homeassistant/components/climate/__init__.py | 7 ++++--- homeassistant/components/climate/device_condition.py | 4 ++-- homeassistant/components/climate/reproduce_state.py | 4 +++- mypy.ini | 10 ++++++++++ 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.strict-typing b/.strict-typing index 4a4151ce606..f49e576a774 100644 --- a/.strict-typing +++ b/.strict-typing @@ -88,6 +88,7 @@ homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* +homeassistant.components.climate.* homeassistant.components.cloud.* homeassistant.components.configurator.* homeassistant.components.cover.* diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 907ff84491b..dfc428a9bd0 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -242,8 +242,9 @@ class ClimateEntity(Entity): hvac_mode = self.hvac_mode if hvac_mode is None: return None + # Support hvac_mode as string for custom integration backwards compatibility if not isinstance(hvac_mode, HVACMode): - return HVACMode(hvac_mode).value + return HVACMode(hvac_mode).value # type: ignore[unreachable] return hvac_mode.value @property @@ -458,11 +459,11 @@ class ClimateEntity(Entity): """ return self._attr_swing_modes - def set_temperature(self, **kwargs) -> None: + def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" raise NotImplementedError() - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" await self.hass.async_add_executor_job( ft.partial(self.set_temperature, **kwargs) diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index d9f1b240a9a..57b9654651b 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -92,9 +92,9 @@ def async_condition_from_config( return False if config[CONF_TYPE] == "is_hvac_mode": - return state.state == config[const.ATTR_HVAC_MODE] + return bool(state.state == config[const.ATTR_HVAC_MODE]) - return ( + return bool( state.attributes.get(const.ATTR_PRESET_MODE) == config[const.ATTR_PRESET_MODE] ) diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 0bbc6fce7ec..2897a956fc6 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -38,7 +38,9 @@ async def _async_reproduce_states( ) -> None: """Reproduce component states.""" - async def call_service(service: str, keys: Iterable, data=None): + async def call_service( + service: str, keys: Iterable, data: dict[str, Any] | None = None + ) -> None: """Call service with set of attributes given.""" data = data or {} data["entity_id"] = state.entity_id diff --git a/mypy.ini b/mypy.ini index 14eb6bba841..6303f2b2706 100644 --- a/mypy.ini +++ b/mypy.ini @@ -641,6 +641,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.climate.*] +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.cloud.*] check_untyped_defs = true disallow_incomplete_defs = true From 54d92b649b035119ed71c822d9327f29d1a16927 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 6 Sep 2023 12:33:58 -0400 Subject: [PATCH 214/984] Raise error on open/close failure in Aladdin Connect (#99746) Raise error on open/close failure --- .../components/aladdin_connect/cover.py | 8 ++++--- .../components/aladdin_connect/test_cover.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index f466f5f4248..604ac61300d 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -10,7 +10,7 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -75,11 +75,13 @@ class AladdinDevice(CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" - await self._acc.close_door(self._device_id, self._number) + if not await self._acc.close_door(self._device_id, self._number): + raise HomeAssistantError("Aladdin Connect API failed to close the cover") async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - await self._acc.open_door(self._device_id, self._number) + if not await self._acc.open_door(self._device_id, self._number): + raise HomeAssistantError("Aladdin Connect API failed to open the cover") async def async_update(self) -> None: """Update status of cover.""" diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index eb617b959a5..ba82ec6589a 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from AIOAladdinConnect import session_manager +import pytest from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL @@ -19,6 +20,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -123,6 +125,17 @@ async def test_cover_operation( ) assert hass.states.get("cover.home").state == STATE_OPEN + mock_aladdinconnect_api.open_door.return_value = False + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + + mock_aladdinconnect_api.open_door.return_value = True + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED @@ -140,6 +153,17 @@ async def test_cover_operation( assert hass.states.get("cover.home").state == STATE_CLOSED + mock_aladdinconnect_api.close_door.return_value = False + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + + mock_aladdinconnect_api.close_door.return_value = True + mock_aladdinconnect_api.async_get_door_status = AsyncMock( return_value=STATE_CLOSING ) From 9bc07f50f9f2e26a45343deb4537a4403e4401cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hol=C3=BD?= Date: Wed, 6 Sep 2023 18:39:33 +0200 Subject: [PATCH 215/984] Add additional fields for 3-phase UPS to nut (#98625) Co-authored-by: J. Nick Koston --- homeassistant/components/nut/sensor.py | 321 ++++++++++++++++++++++ homeassistant/components/nut/strings.json | 42 +++ 2 files changed, 363 insertions(+) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 9151a86a9f8..165db8bb704 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -491,6 +491,33 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1-N.voltage": SensorEntityDescription( + key="input.L1-N.voltage", + translation_key="input_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2-N.voltage": SensorEntityDescription( + key="input.L2-N.voltage", + translation_key="input_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3-N.voltage": SensorEntityDescription( + key="input.L3-N.voltage", + translation_key="input_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.frequency": SensorEntityDescription( key="input.frequency", translation_key="input_frequency", @@ -515,6 +542,69 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.frequency": SensorEntityDescription( + key="input.L1.frequency", + translation_key="input_l1_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.frequency": SensorEntityDescription( + key="input.L2.frequency", + translation_key="input_l2_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.frequency": SensorEntityDescription( + key="input.L3.frequency", + translation_key="input_l3_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.current": SensorEntityDescription( + key="input.bypass.current", + translation_key="input_bypass_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.current": SensorEntityDescription( + key="input.bypass.L1.current", + translation_key="input_bypass_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.current": SensorEntityDescription( + key="input.bypass.L2.current", + translation_key="input_bypass_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.current": SensorEntityDescription( + key="input.bypass.L3.current", + translation_key="input_bypass_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.bypass.frequency": SensorEntityDescription( key="input.bypass.frequency", translation_key="input_bypass_frequency", @@ -531,6 +621,78 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.bypass.realpower": SensorEntityDescription( + key="input.bypass.realpower", + translation_key="input_bypass_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.realpower": SensorEntityDescription( + key="input.bypass.L1.realpower", + translation_key="input_bypass_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.realpower": SensorEntityDescription( + key="input.bypass.L2.realpower", + translation_key="input_bypass_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.realpower": SensorEntityDescription( + key="input.bypass.L3.realpower", + translation_key="input_bypass_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.voltage": SensorEntityDescription( + key="input.bypass.voltage", + translation_key="input_bypass_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1-N.voltage": SensorEntityDescription( + key="input.bypass.L1-N.voltage", + translation_key="input_bypass_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2-N.voltage": SensorEntityDescription( + key="input.bypass.L2-N.voltage", + translation_key="input_bypass_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3-N.voltage": SensorEntityDescription( + key="input.bypass.L3-N.voltage", + translation_key="input_bypass_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.current": SensorEntityDescription( key="input.current", translation_key="input_current", @@ -540,6 +702,33 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.current": SensorEntityDescription( + key="input.L1.current", + translation_key="input_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.current": SensorEntityDescription( + key="input.L2.current", + translation_key="input_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.current": SensorEntityDescription( + key="input.L3.current", + translation_key="input_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.phases": SensorEntityDescription( key="input.phases", translation_key="input_phases", @@ -556,6 +745,33 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.realpower": SensorEntityDescription( + key="input.L1.realpower", + translation_key="input_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.realpower": SensorEntityDescription( + key="input.L2.realpower", + translation_key="input_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.realpower": SensorEntityDescription( + key="input.L3.realpower", + translation_key="input_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.power.nominal": SensorEntityDescription( key="output.power.nominal", translation_key="output_power_nominal", @@ -564,6 +780,30 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.power.percent": SensorEntityDescription( + key="output.L1.power.percent", + translation_key="output_l1_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.power.percent": SensorEntityDescription( + key="output.L2.power.percent", + translation_key="output_l2_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.power.percent": SensorEntityDescription( + key="output.L3.power.percent", + translation_key="output_l3_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.current": SensorEntityDescription( key="output.current", translation_key="output_current", @@ -581,6 +821,33 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.current": SensorEntityDescription( + key="output.L1.current", + translation_key="output_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.current": SensorEntityDescription( + key="output.L2.current", + translation_key="output_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.current": SensorEntityDescription( + key="output.L3.current", + translation_key="output_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.voltage": SensorEntityDescription( key="output.voltage", translation_key="output_voltage", @@ -596,6 +863,33 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1-N.voltage": SensorEntityDescription( + key="output.L1-N.voltage", + translation_key="output_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2-N.voltage": SensorEntityDescription( + key="output.L2-N.voltage", + translation_key="output_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3-N.voltage": SensorEntityDescription( + key="output.L3-N.voltage", + translation_key="output_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.frequency": SensorEntityDescription( key="output.frequency", translation_key="output_frequency", @@ -646,6 +940,33 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.realpower": SensorEntityDescription( + key="output.L1.realpower", + translation_key="output_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.realpower": SensorEntityDescription( + key="output.L2.realpower", + translation_key="output_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.realpower": SensorEntityDescription( + key="output.L3.realpower", + translation_key="output_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "ambient.humidity": SensorEntityDescription( key="ambient.humidity", translation_key="ambient_humidity", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index a07e0ec2f7c..2827911a3aa 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -90,31 +90,73 @@ "battery_voltage_high": { "name": "High battery voltage" }, "battery_voltage_low": { "name": "Low battery voltage" }, "battery_voltage_nominal": { "name": "Nominal battery voltage" }, + "input_bypass_current": { "name": "Input bypass current" }, + "input_bypass_l1_current": { "name": "Input bypass L1 current" }, + "input_bypass_l2_current": { "name": "Input bypass L2 current" }, + "input_bypass_l3_current": { "name": "Input bypass L3 current" }, + "input_bypass_voltage": { "name": "Input bypass voltage" }, + "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, + "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, + "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, "input_bypass_phases": { "name": "Input bypass phases" }, + "input_bypass_realpower": { "name": "Current input bypass real power" }, + "input_bypass_l1_realpower": { + "name": "Current input bypass L1 real power" + }, + "input_bypass_l2_realpower": { + "name": "Current input bypass L2 real power" + }, + "input_bypass_l3_realpower": { + "name": "Current input bypass L3 real power" + }, "input_current": { "name": "Input current" }, + "input_l1_current": { "name": "Input L1 current" }, + "input_l2_current": { "name": "Input L2 current" }, + "input_l3_current": { "name": "Input L3 current" }, "input_frequency": { "name": "Input line frequency" }, "input_frequency_nominal": { "name": "Nominal input line frequency" }, "input_frequency_status": { "name": "Input frequency status" }, + "input_l1_frequency": { "name": "Input L1 line frequency" }, + "input_l2_frequency": { "name": "Input L2 line frequency" }, + "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, "input_realpower": { "name": "Current input real power" }, + "input_l1_realpower": { "name": "Current input L1 real power" }, + "input_l2_realpower": { "name": "Current input L2 real power" }, + "input_l3_realpower": { "name": "Current input L3 real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, "input_transfer_reason": { "name": "Voltage transfer reason" }, "input_voltage": { "name": "Input voltage" }, "input_voltage_nominal": { "name": "Nominal input voltage" }, + "input_l1_n_voltage": { "name": "Input L1 voltage" }, + "input_l2_n_voltage": { "name": "Input L2 voltage" }, + "input_l3_n_voltage": { "name": "Input L3 voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, + "output_l1_current": { "name": "Output L1 current" }, + "output_l2_current": { "name": "Output L2 current" }, + "output_l3_current": { "name": "Output L3 current" }, "output_frequency": { "name": "Output frequency" }, "output_frequency_nominal": { "name": "Nominal output frequency" }, "output_phases": { "name": "Output phases" }, "output_power": { "name": "Output apparent power" }, + "output_l2_power_percent": { "name": "Output L2 power usage" }, + "output_l1_power_percent": { "name": "Output L1 power usage" }, + "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, "output_realpower": { "name": "Current output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, + "output_l1_realpower": { "name": "Current output L1 real power" }, + "output_l2_realpower": { "name": "Current output L2 real power" }, + "output_l3_realpower": { "name": "Current output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, + "output_l1_n_voltage": { "name": "Output L1-N voltage" }, + "output_l2_n_voltage": { "name": "Output L2-N voltage" }, + "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "ups_alarm": { "name": "Alarms" }, "ups_beeper_status": { "name": "Beeper status" }, "ups_contacts": { "name": "External contacts" }, From 7a6c8767b3f3a06d59dfac1ccdc5cb3ce7733687 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Sep 2023 18:51:38 +0200 Subject: [PATCH 216/984] Improve typing of trend component (#99719) * Some typing in trend component * Add missing type hint * Enable strict typing on trend --- .strict-typing | 1 + .../components/trend/binary_sensor.py | 37 ++++++++++--------- mypy.ini | 10 +++++ 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.strict-typing b/.strict-typing index f49e576a774..30d20a6fc54 100644 --- a/.strict-typing +++ b/.strict-typing @@ -336,6 +336,7 @@ homeassistant.components.trafikverket_camera.* homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* +homeassistant.components.trend.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 815403e1e87..089e82b0f07 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -2,8 +2,10 @@ from __future__ import annotations from collections import deque +from collections.abc import Mapping import logging import math +from typing import Any import numpy as np import voluptuous as vol @@ -12,6 +14,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import ( @@ -117,20 +120,22 @@ class SensorTrend(BinarySensorEntity): """Representation of a trend Sensor.""" _attr_should_poll = False + _gradient = 0.0 + _state: bool | None = None def __init__( self, - hass, - device_id, - friendly_name, - entity_id, - attribute, - device_class, - invert, - max_samples, - min_gradient, - sample_duration, - ): + hass: HomeAssistant, + device_id: str, + friendly_name: str, + entity_id: str, + attribute: str, + device_class: BinarySensorDeviceClass, + invert: bool, + max_samples: int, + min_gradient: float, + sample_duration: int, + ) -> None: """Initialize the sensor.""" self._hass = hass self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) @@ -141,17 +146,15 @@ class SensorTrend(BinarySensorEntity): self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient - self._gradient = None - self._state = None - self.samples = deque(maxlen=max_samples) + self.samples: deque = deque(maxlen=max_samples) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, @@ -214,7 +217,7 @@ class SensorTrend(BinarySensorEntity): if self._invert: self._state = not self._state - def _calculate_gradient(self): + def _calculate_gradient(self) -> None: """Compute the linear trend gradient of the current samples. This need run inside executor. diff --git a/mypy.ini b/mypy.ini index 6303f2b2706..1c3fc1a52ed 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3123,6 +3123,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trend.*] +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.tts.*] check_untyped_defs = true disallow_incomplete_defs = true From 7c7456df99f2ab9bc51a2076d573e23a2ea40e54 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 Sep 2023 18:54:16 +0200 Subject: [PATCH 217/984] Handle alexa invalid climate temp adjustment (#99740) * Handle temp adjust when target state not set * Update homeassistant/components/alexa/errors.py Co-authored-by: Robert Resch * black --------- Co-authored-by: Robert Resch --- homeassistant/components/alexa/errors.py | 7 +++ homeassistant/components/alexa/handlers.py | 9 ++- tests/components/alexa/test_smart_home.py | 69 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 2c5ced62403..f8e3720e160 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -90,6 +90,13 @@ class AlexaUnsupportedThermostatModeError(AlexaError): error_type = "UNSUPPORTED_THERMOSTAT_MODE" +class AlexaUnsupportedThermostatTargetStateError(AlexaError): + """Class to represent unsupported climate target state error.""" + + namespace = "Alexa.ThermostatController" + error_type = "INVALID_TARGET_STATE" + + class AlexaTempRangeError(AlexaError): """Class to represent TempRange errors.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 3e995e9ffe2..f99b0231e4d 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -73,6 +73,7 @@ from .errors import ( AlexaSecurityPanelAuthorizationRequired, AlexaTempRangeError, AlexaUnsupportedThermostatModeError, + AlexaUnsupportedThermostatTargetStateError, AlexaVideoActionNotPermittedForContentError, ) from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_mode @@ -911,7 +912,13 @@ async def async_api_adjust_target_temp( } ) else: - target_temp = float(entity.attributes[ATTR_TEMPERATURE]) + temp_delta + current_target_temp: str | None = entity.attributes.get(ATTR_TEMPERATURE) + if current_target_temp is None: + raise AlexaUnsupportedThermostatTargetStateError( + "The current target temperature is not set, " + "cannot adjust target temperature" + ) + target_temp = float(current_target_temp) + temp_delta if target_temp < min_temp or target_temp > max_temp: raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index c42ea0a0f6a..bbdf3efeb5f 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2471,6 +2471,75 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_no_current_target_temp_adjusting_temp(hass: HomeAssistant) -> None: + """Test thermostat adjusting temp with no initial target temperature.""" + hass.config.units = US_CUSTOMARY_SYSTEM + device = ( + "climate.test_thermostat", + "cool", + { + "temperature": None, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 75.0, + "friendly_name": "Test Thermostat", + "supported_features": 1 | 2 | 4 | 128, + "hvac_modes": ["off", "heat", "cool", "auto", "dry", "fan_only"], + "preset_mode": None, + "preset_modes": ["eco"], + "min_temp": 50, + "max_temp": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "climate#test_thermostat" + assert appliance["displayCategories"][0] == "THERMOSTAT" + assert appliance["friendlyName"] == "Test Thermostat" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "climate#test_thermostat") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "COOL") + properties.assert_not_has_property( + "Alexa.ThermostatController", + "targetSetpoint", + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"} + ) + + thermostat_capability = get_capability(capabilities, "Alexa.ThermostatController") + assert thermostat_capability is not None + configuration = thermostat_capability["configuration"] + assert configuration["supportsScheduling"] is False + + supported_modes = ["OFF", "HEAT", "COOL", "AUTO", "ECO", "CUSTOM"] + for mode in supported_modes: + assert mode in configuration["supportedModes"] + + # Adjust temperature where target temp is not set + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -5.0, "scale": "KELVIN"}}, + ) + assert msg["event"]["payload"]["type"] == "INVALID_TARGET_STATE" + assert msg["event"]["payload"]["message"] == ( + "The current target temperature is not set, cannot adjust target temperature" + ) + + async def test_thermostat_dual(hass: HomeAssistant) -> None: """Test thermostat discovery with auto mode, with upper and lower target temperatures.""" hass.config.units = US_CUSTOMARY_SYSTEM From fdf902e0532d478585229d0c99a3eaf53fcac599 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 12:37:42 -0500 Subject: [PATCH 218/984] Bump zeroconf to 0.98.0 (#99748) --- 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 4969b2a5a65..117744a2775 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.97.0"] + "requirements": ["zeroconf==0.98.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e0d75c1ec20..0022f0bc037 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.97.0 +zeroconf==0.98.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 f038ef9d247..cb4b40d4172 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.97.0 +zeroconf==0.98.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73af7791cd9..b92f5483cc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.97.0 +zeroconf==0.98.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 533350b94ab766c8e8c7868a1404a1c26769d7b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 13:21:21 -0500 Subject: [PATCH 219/984] Bump dbus-fast to 1.95.0 (#99749) --- 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 e1a5ee41324..bcb371971a6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.11.0", - "dbus-fast==1.94.1" + "dbus-fast==1.95.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0022f0bc037..0624415b11c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.94.1 +dbus-fast==1.95.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index cb4b40d4172..b959d43886f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.94.1 +dbus-fast==1.95.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b92f5483cc8..c9dc2f9184f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.94.1 +dbus-fast==1.95.0 # homeassistant.components.debugpy debugpy==1.6.7 From b0e46f425f9147d8117cd50a0c8c31ca0dafd39d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 21:50:48 +0200 Subject: [PATCH 220/984] Remove deprecated entities from OpenTherm Gateway (#99712) --- .../components/opentherm_gw/binary_sensor.py | 58 ---------- .../components/opentherm_gw/const.py | 103 ------------------ .../components/opentherm_gw/sensor.py | 67 +----------- 3 files changed, 1 insertion(+), 227 deletions(-) diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 2501d00c2eb..7f2a05ddf03 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -1,12 +1,10 @@ """Support for OpenTherm Gateway binary sensors.""" import logging -from pprint import pformat from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import async_generate_entity_id @@ -17,7 +15,6 @@ from .const import ( BINARY_SENSOR_INFO, DATA_GATEWAYS, DATA_OPENTHERM_GW, - DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP, TRANSLATE_SOURCE, ) @@ -31,9 +28,7 @@ async def async_setup_entry( ) -> None: """Set up the OpenTherm Gateway binary sensors.""" sensors = [] - deprecated_sensors = [] gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] - ent_reg = er.async_get(hass) for var, info in BINARY_SENSOR_INFO.items(): device_class = info[0] friendly_name_format = info[1] @@ -50,36 +45,6 @@ async def async_setup_entry( ) ) - old_style_entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - old_ent = ent_reg.async_get(old_style_entity_id) - if old_ent and old_ent.config_entry_id == config_entry.entry_id: - if old_ent.disabled: - ent_reg.async_remove(old_style_entity_id) - else: - deprecated_sensors.append( - DeprecatedOpenThermBinarySensor( - gw_dev, - var, - device_class, - friendly_name_format, - ) - ) - - sensors.extend(deprecated_sensors) - - if deprecated_sensors: - _LOGGER.warning( - ( - "The following binary_sensor entities are deprecated and may " - "no longer behave as expected. They will be removed in a " - "future version. You can force removal of these entities by " - "disabling them and restarting Home Assistant.\n%s" - ), - pformat([s.entity_id for s in deprecated_sensors]), - ) - async_add_entities(sensors) @@ -166,26 +131,3 @@ class OpenThermBinarySensor(BinarySensorEntity): def device_class(self): """Return the class of this device.""" return self._device_class - - -class DeprecatedOpenThermBinarySensor(OpenThermBinarySensor): - """Represent a deprecated OpenTherm Gateway Binary Sensor.""" - - # pylint: disable=super-init-not-called - def __init__(self, gw_dev, var, device_class, friendly_name_format): - """Initialize the binary sensor.""" - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - self._gateway = gw_dev - self._var = var - self._source = DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP[var] - self._state = None - self._device_class = device_class - self._friendly_name = friendly_name_format.format(gw_dev.name) - self._unsub_updates = None - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._var}" diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 1532b787740..a6c75c17113 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -535,106 +535,3 @@ SENSOR_INFO: dict[str, list] = { [gw_vars.OTGW], ], } - -DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP = { - gw_vars.DATA_MASTER_CH_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_DHW_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_OTC_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_CH2_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_FAULT_IND: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_FLAME_ON: gw_vars.BOILER, - gw_vars.DATA_SLAVE_COOLING_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH2_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DIAG_IND: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_PRESENT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CONTROL_TYPE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_COOLING_SUPPORTED: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_CONFIG: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH2_PRESENT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_SERVICE_REQ: gw_vars.BOILER, - gw_vars.DATA_SLAVE_REMOTE_RESET: gw_vars.BOILER, - gw_vars.DATA_SLAVE_LOW_WATER_PRESS: gw_vars.BOILER, - gw_vars.DATA_SLAVE_GAS_FAULT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_AIR_PRESS_FAULT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_WATER_OVERTEMP: gw_vars.BOILER, - gw_vars.DATA_REMOTE_TRANSFER_DHW: gw_vars.BOILER, - gw_vars.DATA_REMOTE_TRANSFER_MAX_CH: gw_vars.BOILER, - gw_vars.DATA_REMOTE_RW_DHW: gw_vars.BOILER, - gw_vars.DATA_REMOTE_RW_MAX_CH: gw_vars.BOILER, - gw_vars.DATA_ROVRD_MAN_PRIO: gw_vars.THERMOSTAT, - gw_vars.DATA_ROVRD_AUTO_PRIO: gw_vars.THERMOSTAT, - gw_vars.OTGW_GPIO_A_STATE: gw_vars.OTGW, - gw_vars.OTGW_GPIO_B_STATE: gw_vars.OTGW, - gw_vars.OTGW_IGNORE_TRANSITIONS: gw_vars.OTGW, - gw_vars.OTGW_OVRD_HB: gw_vars.OTGW, -} - -DEPRECATED_SENSOR_SOURCE_LOOKUP = { - gw_vars.DATA_CONTROL_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_MASTER_MEMBERID: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_MEMBERID: gw_vars.BOILER, - gw_vars.DATA_SLAVE_OEM_FAULT: gw_vars.BOILER, - gw_vars.DATA_COOLING_CONTROL: gw_vars.BOILER, - gw_vars.DATA_CONTROL_SETPOINT_2: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT_OVRD: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MAX_CAPACITY: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT: gw_vars.THERMOSTAT, - gw_vars.DATA_REL_MOD_LEVEL: gw_vars.BOILER, - gw_vars.DATA_CH_WATER_PRESS: gw_vars.BOILER, - gw_vars.DATA_DHW_FLOW_RATE: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT_2: gw_vars.THERMOSTAT, - gw_vars.DATA_ROOM_TEMP: gw_vars.THERMOSTAT, - gw_vars.DATA_CH_WATER_TEMP: gw_vars.BOILER, - gw_vars.DATA_DHW_TEMP: gw_vars.BOILER, - gw_vars.DATA_OUTSIDE_TEMP: gw_vars.THERMOSTAT, - gw_vars.DATA_RETURN_WATER_TEMP: gw_vars.BOILER, - gw_vars.DATA_SOLAR_STORAGE_TEMP: gw_vars.BOILER, - gw_vars.DATA_SOLAR_COLL_TEMP: gw_vars.BOILER, - gw_vars.DATA_CH_WATER_TEMP_2: gw_vars.BOILER, - gw_vars.DATA_DHW_TEMP_2: gw_vars.BOILER, - gw_vars.DATA_EXHAUST_TEMP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_MAX_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_MIN_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_MAX_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_MIN_SETP: gw_vars.BOILER, - gw_vars.DATA_DHW_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_MAX_CH_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_OEM_DIAG: gw_vars.BOILER, - gw_vars.DATA_TOTAL_BURNER_STARTS: gw_vars.BOILER, - gw_vars.DATA_CH_PUMP_STARTS: gw_vars.BOILER, - gw_vars.DATA_DHW_PUMP_STARTS: gw_vars.BOILER, - gw_vars.DATA_DHW_BURNER_STARTS: gw_vars.BOILER, - gw_vars.DATA_TOTAL_BURNER_HOURS: gw_vars.BOILER, - gw_vars.DATA_CH_PUMP_HOURS: gw_vars.BOILER, - gw_vars.DATA_DHW_PUMP_HOURS: gw_vars.BOILER, - gw_vars.DATA_DHW_BURNER_HOURS: gw_vars.BOILER, - gw_vars.DATA_MASTER_OT_VERSION: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_OT_VERSION: gw_vars.BOILER, - gw_vars.DATA_MASTER_PRODUCT_TYPE: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_PRODUCT_VERSION: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_PRODUCT_TYPE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_PRODUCT_VERSION: gw_vars.BOILER, - gw_vars.OTGW_MODE: gw_vars.OTGW, - gw_vars.OTGW_DHW_OVRD: gw_vars.OTGW, - gw_vars.OTGW_ABOUT: gw_vars.OTGW, - gw_vars.OTGW_BUILD: gw_vars.OTGW, - gw_vars.OTGW_CLOCKMHZ: gw_vars.OTGW, - gw_vars.OTGW_LED_A: gw_vars.OTGW, - gw_vars.OTGW_LED_B: gw_vars.OTGW, - gw_vars.OTGW_LED_C: gw_vars.OTGW, - gw_vars.OTGW_LED_D: gw_vars.OTGW, - gw_vars.OTGW_LED_E: gw_vars.OTGW, - gw_vars.OTGW_LED_F: gw_vars.OTGW, - gw_vars.OTGW_GPIO_A: gw_vars.OTGW, - gw_vars.OTGW_GPIO_B: gw_vars.OTGW, - gw_vars.OTGW_SB_TEMP: gw_vars.OTGW, - gw_vars.OTGW_SETP_OVRD_MODE: gw_vars.OTGW, - gw_vars.OTGW_SMART_PWR: gw_vars.OTGW, - gw_vars.OTGW_THRM_DETECT: gw_vars.OTGW, - gw_vars.OTGW_VREF: gw_vars.OTGW, -} diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index b219969e71a..df9260d7d19 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -1,25 +1,17 @@ """Support for OpenTherm Gateway sensors.""" import logging -from pprint import pformat from homeassistant.components.sensor import ENTITY_ID_FORMAT, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN -from .const import ( - DATA_GATEWAYS, - DATA_OPENTHERM_GW, - DEPRECATED_SENSOR_SOURCE_LOOKUP, - SENSOR_INFO, - TRANSLATE_SOURCE, -) +from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, SENSOR_INFO, TRANSLATE_SOURCE _LOGGER = logging.getLogger(__name__) @@ -31,9 +23,7 @@ async def async_setup_entry( ) -> None: """Set up the OpenTherm Gateway sensors.""" sensors = [] - deprecated_sensors = [] gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] - ent_reg = er.async_get(hass) for var, info in SENSOR_INFO.items(): device_class = info[0] unit = info[1] @@ -52,37 +42,6 @@ async def async_setup_entry( ) ) - old_style_entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - old_ent = ent_reg.async_get(old_style_entity_id) - if old_ent and old_ent.config_entry_id == config_entry.entry_id: - if old_ent.disabled: - ent_reg.async_remove(old_style_entity_id) - else: - deprecated_sensors.append( - DeprecatedOpenThermSensor( - gw_dev, - var, - device_class, - unit, - friendly_name_format, - ) - ) - - sensors.extend(deprecated_sensors) - - if deprecated_sensors: - _LOGGER.warning( - ( - "The following sensor entities are deprecated and may no " - "longer behave as expected. They will be removed in a future " - "version. You can force removal of these entities by disabling " - "them and restarting Home Assistant.\n%s" - ), - pformat([s.entity_id for s in deprecated_sensors]), - ) - async_add_entities(sensors) @@ -175,27 +134,3 @@ class OpenThermSensor(SensorEntity): def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit - - -class DeprecatedOpenThermSensor(OpenThermSensor): - """Represent a deprecated OpenTherm Gateway Sensor.""" - - # pylint: disable=super-init-not-called - def __init__(self, gw_dev, var, device_class, unit, friendly_name_format): - """Initialize the OpenTherm Gateway sensor.""" - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - self._gateway = gw_dev - self._var = var - self._source = DEPRECATED_SENSOR_SOURCE_LOOKUP[var] - self._value = None - self._device_class = device_class - self._unit = unit - self._friendly_name = friendly_name_format.format(gw_dev.name) - self._unsub_updates = None - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._var}" From 61b02e9c66e127917a1682eb2ffe19a44d5946f6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 23:34:39 +0200 Subject: [PATCH 221/984] Use shorthand attributes in Progetti (#99772) Use shorthand attributes in Progetti shorthand --- homeassistant/components/progettihwsw/binary_sensor.py | 7 +------ homeassistant/components/progettihwsw/switch.py | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index e2d1025cc64..ea7a7dce5c3 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -62,14 +62,9 @@ class ProgettihwswBinarySensor(CoordinatorEntity, BinarySensorEntity): def __init__(self, coordinator, name, sensor: Input) -> None: """Set initializing values.""" super().__init__(coordinator) - self._name = name + self._attr_name = name self._sensor = sensor - @property - def name(self): - """Return the sensor name.""" - return self._name - @property def is_on(self): """Get sensor state.""" diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 77cfb6ba4d1..f466e11a1cc 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -64,7 +64,7 @@ class ProgettihwswSwitch(CoordinatorEntity, SwitchEntity): """Initialize the values.""" super().__init__(coordinator) self._switch = switch - self._name = name + self._attr_name = name async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -81,11 +81,6 @@ class ProgettihwswSwitch(CoordinatorEntity, SwitchEntity): await self._switch.toggle() await self.coordinator.async_request_refresh() - @property - def name(self): - """Return the switch name.""" - return self._name - @property def is_on(self): """Get switch state.""" From 3afdecd51f70cf90c6e265f9697436509dc4e68c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 23:37:31 +0200 Subject: [PATCH 222/984] Use shorthand attributes in Plum (#99770) Use shorthand attributes in Plum shorthand --- .../components/plum_lightpad/light.py | 74 +++++-------------- 1 file changed, 20 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index 2c1f7daa880..9464e66e3a9 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -73,6 +73,14 @@ class PlumLight(LightEntity): """Initialize the light.""" self._load = load self._brightness = load.level + unique_id = f"{load.llid}.light" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Plum", + model="Dimmer", + name=load.name, + ) async def async_added_to_hass(self) -> None: """Subscribe to dimmerchange events.""" @@ -83,21 +91,6 @@ class PlumLight(LightEntity): self._brightness = event["level"] self.schedule_update_ha_state() - @property - def unique_id(self): - """Combine logical load ID with .light to guarantee it is unique.""" - return f"{self._load.llid}.light" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Plum", - model="Dimmer", - name=self._load.name, - ) - @property def brightness(self) -> int: """Return the brightness of this switch between 0..255.""" @@ -138,18 +131,27 @@ class GlowRing(LightEntity): _attr_color_mode = ColorMode.HS _attr_should_poll = False _attr_supported_color_modes = {ColorMode.HS} + _attr_icon = "mdi:crop-portrait" def __init__(self, lightpad): """Initialize the light.""" self._lightpad = lightpad - self._name = f"{lightpad.friendly_name} Glow Ring" + self._attr_name = f"{lightpad.friendly_name} Glow Ring" - self._state = lightpad.glow_enabled + self._attr_is_on = lightpad.glow_enabled self._glow_intensity = lightpad.glow_intensity + unique_id = f"{self._lightpad.lpid}.glow" + self._attr_unique_id = unique_id self._red = lightpad.glow_color["red"] self._green = lightpad.glow_color["green"] self._blue = lightpad.glow_color["blue"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Plum", + model="Glow Ring", + name=self._attr_name, + ) async def async_added_to_hass(self) -> None: """Subscribe to configchange events.""" @@ -159,13 +161,12 @@ class GlowRing(LightEntity): """Handle Configuration change event.""" config = event["changes"] - self._state = config["glowEnabled"] + self._attr_is_on = config["glowEnabled"] self._glow_intensity = config["glowIntensity"] self._red = config["glowColor"]["red"] self._green = config["glowColor"]["green"] self._blue = config["glowColor"]["blue"] - self.schedule_update_ha_state() @property @@ -173,46 +174,11 @@ class GlowRing(LightEntity): """Return the hue and saturation color value [float, float].""" return color_util.color_RGB_to_hs(self._red, self._green, self._blue) - @property - def unique_id(self): - """Combine LightPad ID with .glow to guarantee it is unique.""" - return f"{self._lightpad.lpid}.glow" - - @property - def name(self): - """Return the name of the switch if any.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Plum", - model="Glow Ring", - name=self.name, - ) - @property def brightness(self) -> int: """Return the brightness of this switch between 0..255.""" return min(max(int(round(self._glow_intensity * 255, 0)), 0), 255) - @property - def glow_intensity(self): - """Brightness in float form.""" - return self._glow_intensity - - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._state - - @property - def icon(self): - """Return the crop-portrait icon representing the glow ring.""" - return "mdi:crop-portrait" - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: From 2565f153cdcd1e72c84cafd8fa3463b33c21d183 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 6 Sep 2023 17:26:14 -0600 Subject: [PATCH 223/984] Bump `aiorecollect` to 2023.09.0 (#99780) --- homeassistant/components/recollect_waste/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index dc31adddb78..e1ad3f98950 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiorecollect"], - "requirements": ["aiorecollect==1.0.8"] + "requirements": ["aiorecollect==2023.09.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b959d43886f..a3c8d80ce7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -328,7 +328,7 @@ aiopyarr==23.4.0 aioqsw==0.3.4 # homeassistant.components.recollect_waste -aiorecollect==1.0.8 +aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2023.07.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9dc2f9184f..7463331ac3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -303,7 +303,7 @@ aiopyarr==23.4.0 aioqsw==0.3.4 # homeassistant.components.recollect_waste -aiorecollect==1.0.8 +aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2023.07.0 From 0c7e0f5cd92f68b7ef772e11f832f68392a2fe46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 20:01:22 -0500 Subject: [PATCH 224/984] Bump sense_energy to 0.12.1 (#99763) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 324279db7d9..d39d530eccc 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.12.0"] + "requirements": ["sense_energy==0.12.1"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 8c20db2e422..8a89d6d8531 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.12.0"] + "requirements": ["sense-energy==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3c8d80ce7d..792a96656ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2371,10 +2371,10 @@ securetar==2023.3.0 sendgrid==6.8.2 # homeassistant.components.sense -sense-energy==0.12.0 +sense-energy==0.12.1 # homeassistant.components.emulated_kasa -sense_energy==0.12.0 +sense_energy==0.12.1 # homeassistant.components.sensirion_ble sensirion-ble==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7463331ac3f..d4cf7e3f79d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1734,10 +1734,10 @@ screenlogicpy==0.8.2 securetar==2023.3.0 # homeassistant.components.sense -sense-energy==0.12.0 +sense-energy==0.12.1 # homeassistant.components.emulated_kasa -sense_energy==0.12.0 +sense_energy==0.12.1 # homeassistant.components.sensirion_ble sensirion-ble==0.1.0 From e1f4a3fa9fea4f8acdda8fa2b5c25ee1a47d1554 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 7 Sep 2023 04:59:04 +0000 Subject: [PATCH 225/984] Add energy meter sensors for Shelly Pro EM (#99747) * Add support for Pro EM * Improve get_rpc_channel_name() * Revert an unintended change * Add tests --- homeassistant/components/shelly/sensor.py | 78 +++++++++++++++++++++++ homeassistant/components/shelly/utils.py | 3 + tests/components/shelly/conftest.py | 4 ++ tests/components/shelly/test_sensor.py | 40 ++++++++++++ 4 files changed, 125 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index abcca888005..99ccd9ab2ff 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -363,6 +363,14 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + "power_em1": RpcSensorDescription( + key="em1", + sub_key="act_power", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "power_pm1": RpcSensorDescription( key="pm1", sub_key="apower", @@ -427,6 +435,14 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, ), + "aprt_power_em1": RpcSensorDescription( + key="em1", + sub_key="aprt_power", + name="Apparent power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "total_aprt_power": RpcSensorDescription( key="em", sub_key="total_aprt_power", @@ -435,6 +451,13 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, ), + "pf_em1": RpcSensorDescription( + key="em1", + sub_key="pf", + name="Power factor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + ), "a_pf": RpcSensorDescription( key="em", sub_key="a_pf", @@ -467,6 +490,17 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "voltage_em1": RpcSensorDescription( + key="em1", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "voltage_pm1": RpcSensorDescription( key="pm1", sub_key="voltage", @@ -515,6 +549,16 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current_em1": RpcSensorDescription( + key="em1", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "current_pm1": RpcSensorDescription( key="pm1", sub_key="current", @@ -605,6 +649,18 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "total_act_energy": RpcSensorDescription( + key="em1data", + sub_key="total_act_energy", + name="Total active energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "a_total_act_energy": RpcSensorDescription( key="emdata", sub_key="a_total_act_energy", @@ -652,6 +708,18 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "total_act_ret_energy": RpcSensorDescription( + key="em1data", + sub_key="total_act_ret_energy", + name="Total active returned energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "a_total_act_ret_energy": RpcSensorDescription( key="emdata", sub_key="a_total_act_ret_energy", @@ -698,6 +766,16 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "freq_em1": RpcSensorDescription( + key="em1", + sub_key="freq", + name="Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "freq_pm1": RpcSensorDescription( key="pm1", sub_key="freq", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index a66b77ed94b..e78b44db15e 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -288,6 +288,7 @@ def get_model_name(info: dict[str, Any]) -> str: def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") + key = key.replace("em1data", "em1") if device.config.get("switch:0"): key = key.replace("input", "switch") device_name = device.name @@ -298,6 +299,8 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: if entity_name is None: if key.startswith(("input:", "light:", "switch:")): return f"{device_name} {key.replace(':', '_')}" + if key.startswith("em1"): + return f"{device_name} EM{key.split(':')[-1]}" return device_name return entity_name diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 797673265a6..e72604260f5 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -202,6 +202,10 @@ MOCK_STATUS_RPC = { "devicepower:0": {"external": {"present": True}}, "temperature:0": {"tC": 22.9}, "illuminance:0": {"lux": 345}, + "em1:0": {"act_power": 85.3}, + "em1:1": {"act_power": 123.3}, + "em1data:0": {"total_act_energy": 123456.4}, + "em1data:1": {"total_act_energy": 987654.3}, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 892d06ad626..a738113f18f 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -408,3 +408,43 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( await hass.async_block_till_done() assert hass.states.get(entity_id).state == "22.9" + + +async def test_rpc_em1_sensors( + hass: HomeAssistant, mock_rpc_device, entity_registry_enabled_by_default: None +) -> None: + """Test RPC sensors for EM1 component.""" + registry = async_get(hass) + await init_integration(hass, 2) + + state = hass.states.get("sensor.test_name_em0_power") + assert state + assert state.state == "85.3" + + entry = registry.async_get("sensor.test_name_em0_power") + assert entry + assert entry.unique_id == "123456789ABC-em1:0-power_em1" + + state = hass.states.get("sensor.test_name_em1_power") + assert state + assert state.state == "123.3" + + entry = registry.async_get("sensor.test_name_em1_power") + assert entry + assert entry.unique_id == "123456789ABC-em1:1-power_em1" + + state = hass.states.get("sensor.test_name_em0_total_active_energy") + assert state + assert state.state == "123.4564" + + entry = registry.async_get("sensor.test_name_em0_total_active_energy") + assert entry + assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" + + state = hass.states.get("sensor.test_name_em1_total_active_energy") + assert state + assert state.state == "987.6543" + + entry = registry.async_get("sensor.test_name_em1_total_active_energy") + assert entry + assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" From 1a22ab77e1af859a27348df3a87fc2a8e226258c Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 7 Sep 2023 10:28:08 +0200 Subject: [PATCH 226/984] Fix Freebox disk free space sensor (#99757) * Fix Freebox disk free space sensor * Add initial value assert to check results --- .coveragerc | 1 - homeassistant/components/freebox/router.py | 7 +++- homeassistant/components/freebox/sensor.py | 13 ++++--- tests/components/freebox/common.py | 27 +++++++++++++ tests/components/freebox/test_sensor.py | 45 ++++++++++++++++++++++ 5 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 tests/components/freebox/common.py create mode 100644 tests/components/freebox/test_sensor.py diff --git a/.coveragerc b/.coveragerc index d28878d8861..c72400392b7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -417,7 +417,6 @@ omit = homeassistant/components/freebox/device_tracker.py homeassistant/components/freebox/home_base.py homeassistant/components/freebox/router.py - homeassistant/components/freebox/sensor.py homeassistant/components/freebox/switch.py homeassistant/components/fritz/common.py homeassistant/components/fritz/device_tracker.py diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 7c83e980540..cd5862a2f80 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -156,7 +156,12 @@ class FreeboxRouter: fbx_disks: list[dict[str, Any]] = await self._api.storage.get_disks() or [] for fbx_disk in fbx_disks: - self.disks[fbx_disk["id"]] = fbx_disk + disk: dict[str, Any] = {**fbx_disk} + disk_part: dict[int, dict[str, Any]] = {} + for fbx_disk_part in fbx_disk["partitions"]: + disk_part[fbx_disk_part["id"]] = fbx_disk_part + disk["partitions"] = disk_part + self.disks[fbx_disk["id"]] = disk async def _update_raids_sensors(self) -> None: """Update Freebox raids.""" diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 901bfc63199..4e7c3910c54 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -95,7 +95,7 @@ async def async_setup_entry( entities.extend( FreeboxDiskSensor(router, disk, partition, description) for disk in router.disks.values() - for partition in disk["partitions"] + for partition in disk["partitions"].values() for description in DISK_PARTITION_SENSORS ) @@ -197,7 +197,8 @@ class FreeboxDiskSensor(FreeboxSensor): ) -> None: """Initialize a Freebox disk sensor.""" super().__init__(router, description) - self._partition = partition + self._disk_id = disk["id"] + self._partition_id = partition["id"] self._attr_name = f"{partition['label']} {description.name}" self._attr_unique_id = ( f"{router.mac} {description.key} {disk['id']} {partition['id']}" @@ -218,10 +219,10 @@ class FreeboxDiskSensor(FreeboxSensor): def async_update_state(self) -> None: """Update the Freebox disk sensor.""" value = None - if self._partition.get("total_bytes"): - value = round( - self._partition["free_bytes"] * 100 / self._partition["total_bytes"], 2 - ) + disk: dict[str, Any] = self._router.disks[self._disk_id] + partition: dict[str, Any] = disk["partitions"][self._partition_id] + if partition.get("total_bytes"): + value = round(partition["free_bytes"] * 100 / partition["total_bytes"], 2) self._attr_native_value = value diff --git a/tests/components/freebox/common.py b/tests/components/freebox/common.py new file mode 100644 index 00000000000..9f7dfd8f92a --- /dev/null +++ b/tests/components/freebox/common.py @@ -0,0 +1,27 @@ +"""Common methods used across tests for Freebox.""" +from unittest.mock import patch + +from homeassistant.components.freebox.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import MOCK_HOST, MOCK_PORT + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platform: str) -> MockConfigEntry: + """Set up the Freebox platform.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + unique_id=MOCK_HOST, + ) + mock_entry.add_to_hass(hass) + + with patch("homeassistant.components.freebox.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py new file mode 100644 index 00000000000..2ebcf8baa04 --- /dev/null +++ b/tests/components/freebox/test_sensor.py @@ -0,0 +1,45 @@ +"""Tests for the Freebox sensors.""" +from copy import deepcopy +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_platform +from .const import DATA_STORAGE_GET_DISKS + +from tests.common import async_fire_time_changed + + +async def test_disk( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test disk sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + # Initial state + assert ( + router().storage.get_disks.return_value[2]["partitions"][0]["total_bytes"] + == 1960000000000 + ) + + assert ( + router().storage.get_disks.return_value[2]["partitions"][0]["free_bytes"] + == 1730000000000 + ) + + assert hass.states.get("sensor.freebox_free_space").state == "88.27" + + # Simulate a changed storage size + data_storage_get_disks_changed = deepcopy(DATA_STORAGE_GET_DISKS) + data_storage_get_disks_changed[2]["partitions"][0]["free_bytes"] = 880000000000 + router().storage.get_disks.return_value = data_storage_get_disks_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_free_space").state == "44.9" From d2f9270bc9155a6ad5763c1bc0eaf543c9e450b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 7 Sep 2023 10:36:49 +0200 Subject: [PATCH 227/984] Add my self as codeowner for airthings_ble (#99799) Update airthings_ble codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/airthings_ble/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 58812a0baf2..0cb1bef6191 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -47,8 +47,8 @@ build.json @home-assistant/supervisor /tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen /tests/components/airthings/ @danielhiversen -/homeassistant/components/airthings_ble/ @vincegio -/tests/components/airthings_ble/ @vincegio +/homeassistant/components/airthings_ble/ @vincegio @LaStrada +/tests/components/airthings_ble/ @vincegio @LaStrada /homeassistant/components/airvisual/ @bachya /tests/components/airvisual/ @bachya /homeassistant/components/airvisual_pro/ @bachya diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index ef9ad3a802e..cb7114ff8ff 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -19,7 +19,7 @@ "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" } ], - "codeowners": ["@vincegio"], + "codeowners": ["@vincegio", "@LaStrada"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", From 9351e79dcb8f515041cee731cbf4641dfac41eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arda=20=C5=9EEREMET?= Date: Thu, 7 Sep 2023 11:53:59 +0300 Subject: [PATCH 228/984] Bump ProgettiHWSW to 0.1.3 (#92668) * Update manifest.json * Update requirements_test_all.txt * Update requirements_all.txt * Updated dependencies file. * Update manifest.json with correct naming convention. Co-authored-by: Martin Hjelmare * Updated requirements. --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/progettihwsw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/progettihwsw/manifest.json b/homeassistant/components/progettihwsw/manifest.json index 6cad66e1360..d5c91fcea10 100644 --- a/homeassistant/components/progettihwsw/manifest.json +++ b/homeassistant/components/progettihwsw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/progettihwsw", "iot_class": "local_polling", "loggers": ["ProgettiHWSW"], - "requirements": ["ProgettiHWSW==0.1.1"] + "requirements": ["ProgettiHWSW==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 792a96656ed..c2e9c2e9569 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -49,7 +49,7 @@ Pillow==10.0.0 PlexAPI==4.13.2 # homeassistant.components.progettihwsw -ProgettiHWSW==0.1.1 +ProgettiHWSW==0.1.3 # homeassistant.components.bluetooth_tracker # PyBluez==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4cf7e3f79d..0ce3babc3ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ Pillow==10.0.0 PlexAPI==4.13.2 # homeassistant.components.progettihwsw -ProgettiHWSW==0.1.1 +ProgettiHWSW==0.1.3 # homeassistant.components.cast PyChromecast==13.0.7 From e5210c582398f77ff48453716571e34ae5309f5c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Sep 2023 12:00:19 +0200 Subject: [PATCH 229/984] Always set severity level flag on render_template error events (#99804) --- homeassistant/components/websocket_api/commands.py | 4 +++- tests/components/websocket_api/test_commands.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 7772bef66f9..a05f2aa8e3f 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -565,7 +565,9 @@ async def handle_render_template( if not report_errors: return connection.send_message( - messages.event_message(msg["id"], {"error": str(result)}) + messages.event_message( + msg["id"], {"error": str(result), "level": "ERROR"} + ) ) return diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 96e79a81716..70f08477a72 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1512,7 +1512,10 @@ async def test_render_template_with_delayed_error( assert msg["id"] == 5 assert msg["type"] == "event" event = msg["event"] - assert event == {"error": "UndefinedError: 'explode' is undefined"} + assert event == { + "error": "UndefinedError: 'explode' is undefined", + "level": "ERROR", + } assert "Template variable error" not in caplog.text assert "Template variable warning" not in caplog.text From 0cc2c27115bd951a07cab41c6183a93e464de014 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 7 Sep 2023 13:16:31 +0300 Subject: [PATCH 230/984] Add strict typing to islamic prayer times (#99585) * Add strict typing to islamic prayer times * fix mypy errors --- .strict-typing | 1 + .../components/islamic_prayer_times/coordinator.py | 7 ++++--- mypy.ini | 10 ++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index 30d20a6fc54..ee97deb9af4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -188,6 +188,7 @@ homeassistant.components.input_select.* homeassistant.components.integration.* homeassistant.components.ipp.* homeassistant.components.iqvia.* +homeassistant.components.islamic_prayer_times.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 1a8b0bf7036..30362c763da 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Any, cast from prayer_times_calculator import PrayerTimesCalculator, exceptions from requests.exceptions import ConnectionError as ConnError @@ -37,7 +38,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim """Return the calculation method.""" return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) - def get_new_prayer_times(self) -> dict[str, str]: + def get_new_prayer_times(self) -> dict[str, Any]: """Fetch prayer times for today.""" calc = PrayerTimesCalculator( latitude=self.hass.config.latitude, @@ -45,7 +46,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim calculation_method=self.calc_method, date=str(dt_util.now().date()), ) - return calc.fetch_prayer_times() + return cast(dict[str, Any], calc.fetch_prayer_times()) @callback def async_schedule_future_update(self, midnight_dt: datetime) -> None: @@ -98,7 +99,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim self.hass, self.async_request_update, next_update_at ) - async def async_request_update(self, *_) -> None: + async def async_request_update(self, _: datetime) -> None: """Request update from coordinator.""" await self.async_request_refresh() diff --git a/mypy.ini b/mypy.ini index 1c3fc1a52ed..eda6f35cdfa 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1642,6 +1642,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.islamic_prayer_times.*] +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.isy994.*] check_untyped_defs = true disallow_incomplete_defs = true From f1ae523ff2f7950d03ff98d0f5e0ca3201e44bda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 05:17:04 -0500 Subject: [PATCH 231/984] Bump pyenphase to 1.9.3 (#99787) * Bump pyenphase to 1.9.2 changelog: https://github.com/pyenphase/pyenphase/compare/v1.9.1...v1.9.2 Handle the case where the user has manually specified a password for local auth with firmware < 7.x but its incorrect. The integration previously accepted any wrong password and would reduce functionality down to what works without a password. We now preserve that behavior to avoid breaking existing installs. * bump --- 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 a45f4f01e49..d3a36b16b60 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.9.1"], + "requirements": ["pyenphase==1.9.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index c2e9c2e9569..b85338c658e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1673,7 +1673,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.9.1 +pyenphase==1.9.3 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ce3babc3ff..a3680b4cfd4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1240,7 +1240,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.9.1 +pyenphase==1.9.3 # homeassistant.components.everlights pyeverlights==0.1.0 From e8dfa7e2c86eaa453153fc191bd5bd29147616c2 Mon Sep 17 00:00:00 2001 From: swamplynx Date: Thu, 7 Sep 2023 06:17:38 -0400 Subject: [PATCH 232/984] Bump pylutron-caseta to v0.18.2 (#99789) * Bump pylutron-caseta to v0.18.2 Minor bump to pylutron-caseta requirement to support wall mounted occupancy sensor device type in latest RA3 firmware. * Update requirements_all.txt for pylutron-caseta 0.18.2 * Update requirements_test_all.txt for pylutron-caseta 0.18.2 --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index feab9744df0..bf6ed32c668 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.18.1"], + "requirements": ["pylutron-caseta==0.18.2"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index b85338c658e..021e7da2c91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1823,7 +1823,7 @@ pylitejet==0.5.0 pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.1 +pylutron-caseta==0.18.2 # homeassistant.components.lutron pylutron==0.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3680b4cfd4..352737e583f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1354,7 +1354,7 @@ pylitejet==0.5.0 pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.1 +pylutron-caseta==0.18.2 # homeassistant.components.mailgun pymailgunner==1.4 From 7c3605c82e3bad803424b413707fb7cd4eef4ac7 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Thu, 7 Sep 2023 12:22:46 +0200 Subject: [PATCH 233/984] Use config entry ID as unique ID and remove dependency to getmac in Minecraft Server (#97837) * Use config entry ID as unique ID * Add entry migration to v2 and and remove helper module * Remove unneeded strings * Add asserts for config, device and entity entries and improve comments * Add debug log for config entry migration * Reset config entry unique ID and use config entry ID instead * Remove unnecessary unique ID debug log * Revert usage of constants for tranlation keys and use dash as delimiter for entity unique id suffix * Revert "Revert usage of constants for tranlation keys and use dash as delimiter for entity unique id suffix" This reverts commit 07de334606054097e914404da04950e952bef6d2. * Remove unused logger in entity module --- .../components/minecraft_server/__init__.py | 150 ++++++++++++++++-- .../minecraft_server/binary_sensor.py | 6 +- .../minecraft_server/config_flow.py | 64 +------- .../components/minecraft_server/const.py | 8 - .../components/minecraft_server/entity.py | 5 +- .../components/minecraft_server/helpers.py | 35 ---- .../components/minecraft_server/manifest.json | 2 +- .../components/minecraft_server/sensor.py | 24 ++- .../components/minecraft_server/strings.json | 6 +- requirements_all.txt | 1 - requirements_test_all.txt | 1 - .../minecraft_server/test_config_flow.py | 45 +----- 12 files changed, 163 insertions(+), 184 deletions(-) delete mode 100644 homeassistant/components/minecraft_server/helpers.py diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index cf0d96af8d2..a13196dffc6 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -7,16 +7,25 @@ from datetime import datetime, timedelta import logging from typing import Any +import aiodns from mcstatus.server import JavaServer from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.event import async_track_time_interval -from . import helpers -from .const import DOMAIN, SCAN_INTERVAL, SIGNAL_NAME_PREFIX +from .const import ( + DOMAIN, + KEY_LATENCY, + KEY_MOTD, + SCAN_INTERVAL, + SIGNAL_NAME_PREFIX, + SRV_RECORD_PREFIX, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -28,15 +37,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: domain_data = hass.data.setdefault(DOMAIN, {}) # Create and store server instance. - assert entry.unique_id - unique_id = entry.unique_id + config_entry_id = entry.entry_id _LOGGER.debug( "Creating server instance for '%s' (%s)", entry.data[CONF_NAME], entry.data[CONF_HOST], ) - server = MinecraftServer(hass, unique_id, entry.data) - domain_data[unique_id] = server + server = MinecraftServer(hass, config_entry_id, entry.data) + domain_data[config_entry_id] = server await server.async_update() server.start_periodic_update() @@ -48,8 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Minecraft Server config entry.""" - unique_id = config_entry.unique_id - server = hass.data[DOMAIN][unique_id] + config_entry_id = config_entry.entry_id + server = hass.data[DOMAIN][config_entry_id] # Unload platforms. unload_ok = await hass.config_entries.async_unload_platforms( @@ -58,11 +66,110 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Clean up. server.stop_periodic_update() - hass.data[DOMAIN].pop(unique_id) + hass.data[DOMAIN].pop(config_entry_id) return unload_ok +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old config entry to a new format.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + # 1 --> 2: Use config entry ID as base for unique IDs. + if config_entry.version == 1: + assert config_entry.unique_id + assert config_entry.entry_id + old_unique_id = config_entry.unique_id + config_entry_id = config_entry.entry_id + + # Migrate config entry. + _LOGGER.debug("Migrating config entry. Resetting unique ID: %s", old_unique_id) + config_entry.unique_id = None + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry) + + # Migrate device. + await _async_migrate_device_identifiers(hass, config_entry, old_unique_id) + + # Migrate entities. + await er.async_migrate_entries(hass, config_entry_id, _migrate_entity_unique_id) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True + + +async def _async_migrate_device_identifiers( + hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None +) -> None: + """Migrate the device identifiers to the new format.""" + device_registry = dr.async_get(hass) + device_entry_found = False + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + assert device_entry + for identifier in device_entry.identifiers: + if identifier[1] == old_unique_id: + # Device found in registry. Update identifiers. + new_identifiers = { + ( + DOMAIN, + config_entry.entry_id, + ) + } + _LOGGER.debug( + "Migrating device identifiers from %s to %s", + device_entry.identifiers, + new_identifiers, + ) + device_registry.async_update_device( + device_id=device_entry.id, new_identifiers=new_identifiers + ) + # Device entry found. Leave inner for loop. + device_entry_found = True + break + + # Leave outer for loop if device entry is already found. + if device_entry_found: + break + + +@callback +def _migrate_entity_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: + """Migrate the unique ID of an entity to the new format.""" + assert entity_entry + + # Different variants of unique IDs are available in version 1: + # 1) SRV record: '-srv-' + # 2) Host & port: '--' + # 3) IP address & port: '--' + unique_id_pieces = entity_entry.unique_id.split("-") + entity_type = unique_id_pieces[2] + + # Handle bug in version 1: Entity type names were used instead of + # keys (e.g. "Protocol Version" instead of "protocol_version"). + new_entity_type = entity_type.lower() + new_entity_type = new_entity_type.replace(" ", "_") + + # Special case 'MOTD': Name and key differs. + if new_entity_type == "world_message": + new_entity_type = KEY_MOTD + + # Special case 'latency_time': Renamed to 'latency'. + if new_entity_type == "latency_time": + new_entity_type = KEY_LATENCY + + new_unique_id = f"{entity_entry.config_entry_id}-{new_entity_type}" + _LOGGER.debug( + "Migrating entity unique ID from %s to %s", + entity_entry.unique_id, + new_unique_id, + ) + + return {"new_unique_id": new_unique_id} + + @dataclass class MinecraftServerData: """Representation of Minecraft server data.""" @@ -122,7 +229,7 @@ class MinecraftServer: # Check if host is a valid SRV record, if not already done. if not self.srv_record_checked: self.srv_record_checked = True - srv_record = await helpers.async_check_srv_record(self._hass, self.host) + srv_record = await self._async_check_srv_record(self.host) if srv_record is not None: _LOGGER.debug( "'%s' is a valid Minecraft SRV record ('%s:%s')", @@ -152,6 +259,27 @@ class MinecraftServer: ) self.online = False + async def _async_check_srv_record(self, host: str) -> dict[str, Any] | None: + """Check if the given host is a valid Minecraft SRV record.""" + srv_record = None + srv_query = None + + try: + srv_query = await aiodns.DNSResolver().query( + host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" + ) + except aiodns.error.DNSError: + # 'host' is not a SRV record. + pass + else: + # 'host' is a valid SRV record, extract the data. + srv_record = { + CONF_HOST: srv_query[0].host, + CONF_PORT: srv_query[0].port, + } + + return srv_record + async def async_update(self, now: datetime | None = None) -> None: """Get server data from 3rd party library and update properties.""" # Check connection status. diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 3589bfab3e2..3721a50b1de 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MinecraftServer -from .const import DOMAIN, ICON_STATUS, KEY_STATUS, NAME_STATUS +from .const import DOMAIN, ICON_STATUS, KEY_STATUS from .entity import MinecraftServerEntity @@ -18,7 +18,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - server = hass.data[DOMAIN][config_entry.unique_id] + server = hass.data[DOMAIN][config_entry.entry_id] # Create entities list. entities = [MinecraftServerStatusBinarySensor(server)] @@ -36,7 +36,7 @@ class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorEntit """Initialize status binary sensor.""" super().__init__( server=server, - type_name=NAME_STATUS, + entity_type=KEY_STATUS, icon=ICON_STATUS, device_class=BinarySensorDeviceClass.CONNECTIVITY, ) diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index c8429284cd8..cdb345df55c 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,23 +1,20 @@ """Config flow for Minecraft Server integration.""" from contextlib import suppress -from functools import partial -import ipaddress -import getmac import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from . import MinecraftServer, helpers +from . import MinecraftServer from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" - VERSION = 1 + VERSION = 2 async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" @@ -26,10 +23,13 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: host = None port = DEFAULT_PORT + title = user_input[CONF_HOST] + # Split address at last occurrence of ':'. address_left, separator, address_right = user_input[CONF_HOST].rpartition( ":" ) + # If no separator is found, 'rpartition' returns ('', '', original_string). if separator == "": host = address_right @@ -41,32 +41,8 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Remove '[' and ']' in case of an IPv6 address. host = host.strip("[]") - # Check if 'host' is a valid IP address and if so, get the MAC address. - ip_address = None - mac_address = None - try: - ip_address = ipaddress.ip_address(host) - except ValueError: - # Host is not a valid IP address. - # Continue with host and port. - pass - else: - # Host is a valid IP address. - if ip_address.version == 4: - # Address type is IPv4. - params = {"ip": host} - else: - # Address type is IPv6. - params = {"ip6": host} - mac_address = await self.hass.async_add_executor_job( - partial(getmac.get_mac_address, **params) - ) - - # Validate IP address (MAC address must be available). - if ip_address is not None and mac_address is None: - errors["base"] = "invalid_ip" # Validate port configuration (limit to user and dynamic port range). - elif (port < 1024) or (port > 65535): + if (port < 1024) or (port > 65535): errors["base"] = "invalid_port" # Validate host and port by checking the server connection. else: @@ -82,34 +58,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Host or port invalid or server not reachable. errors["base"] = "cannot_connect" else: - # Build unique_id and config entry title. - unique_id = "" - title = f"{host}:{port}" - if ip_address is not None: - # Since IP addresses can change and therefore are not allowed - # in a unique_id, fall back to the MAC address and port (to - # support servers with same MAC address but different ports). - unique_id = f"{mac_address}-{port}" - if ip_address.version == 6: - title = f"[{host}]:{port}" - else: - # Check if 'host' is a valid SRV record. - srv_record = await helpers.async_check_srv_record( - self.hass, host - ) - if srv_record is not None: - # Use only SRV host name in unique_id (does not change). - unique_id = f"{host}-srv" - title = host - else: - # Use host name and port in unique_id (to support servers - # with same host name but different ports). - unique_id = f"{host}-{port}" - - # Abort in case the host was already configured before. - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - # Configuration data are available and no error was detected, # create configuration entry. return self.async_create_entry(title=title, data=config_data) diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 72a891138c4..5b59913c790 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -26,14 +26,6 @@ KEY_MOTD = "motd" MANUFACTURER = "Mojang AB" -NAME_LATENCY = "Latency Time" -NAME_PLAYERS_MAX = "Players Max" -NAME_PLAYERS_ONLINE = "Players Online" -NAME_PROTOCOL_VERSION = "Protocol Version" -NAME_STATUS = "Status" -NAME_VERSION = "Version" -NAME_MOTD = "World Message" - SCAN_INTERVAL = 60 SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 63d68d0aa77..9048cb94004 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,5 +1,6 @@ """Base entity for the Minecraft Server integration.""" + from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -18,14 +19,14 @@ class MinecraftServerEntity(Entity): def __init__( self, server: MinecraftServer, - type_name: str, + entity_type: str, icon: str, device_class: str | None, ) -> None: """Initialize base entity.""" self._server = server self._attr_icon = icon - self._attr_unique_id = f"{self._server.unique_id}-{type_name}" + self._attr_unique_id = f"{self._server.unique_id}-{entity_type}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._server.unique_id)}, manufacturer=MANUFACTURER, diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py deleted file mode 100644 index d4a49d96f83..00000000000 --- a/homeassistant/components/minecraft_server/helpers.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Helper functions for the Minecraft Server integration.""" -from __future__ import annotations - -from typing import Any - -import aiodns - -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant - -from .const import SRV_RECORD_PREFIX - - -async def async_check_srv_record( - hass: HomeAssistant, host: str -) -> dict[str, Any] | None: - """Check if the given host is a valid Minecraft SRV record.""" - # Check if 'host' is a valid SRV record. - return_value = None - srv_records = None - try: - srv_records = await aiodns.DNSResolver().query( - host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" - ) - except aiodns.error.DNSError: - # 'host' is not a SRV record. - pass - else: - # 'host' is a valid SRV record, extract the data. - return_value = { - CONF_HOST: srv_records[0].host, - CONF_PORT: srv_records[0].port, - } - - return return_value diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 27019cb80a8..758f22b1e9a 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "silver", - "requirements": ["aiodns==3.0.0", "getmac==0.8.2", "mcstatus==11.0.0"] + "requirements": ["aiodns==3.0.0", "mcstatus==11.0.0"] } diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 74422675718..e17050310a8 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -23,12 +23,6 @@ from .const import ( KEY_PLAYERS_ONLINE, KEY_PROTOCOL_VERSION, KEY_VERSION, - NAME_LATENCY, - NAME_MOTD, - NAME_PLAYERS_MAX, - NAME_PLAYERS_ONLINE, - NAME_PROTOCOL_VERSION, - NAME_VERSION, UNIT_PLAYERS_MAX, UNIT_PLAYERS_ONLINE, ) @@ -41,7 +35,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - server = hass.data[DOMAIN][config_entry.unique_id] + server = hass.data[DOMAIN][config_entry.entry_id] # Create entities list. entities = [ @@ -63,13 +57,13 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): def __init__( self, server: MinecraftServer, - type_name: str, + entity_type: str, icon: str, unit: str | None = None, device_class: str | None = None, ) -> None: """Initialize sensor base entity.""" - super().__init__(server, type_name, icon, device_class) + super().__init__(server, entity_type, icon, device_class) self._attr_native_unit_of_measurement = unit @property @@ -85,7 +79,7 @@ class MinecraftServerVersionSensor(MinecraftServerSensorEntity): def __init__(self, server: MinecraftServer) -> None: """Initialize version sensor.""" - super().__init__(server=server, type_name=NAME_VERSION, icon=ICON_VERSION) + super().__init__(server=server, entity_type=KEY_VERSION, icon=ICON_VERSION) async def async_update(self) -> None: """Update version.""" @@ -101,7 +95,7 @@ class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): """Initialize protocol version sensor.""" super().__init__( server=server, - type_name=NAME_PROTOCOL_VERSION, + entity_type=KEY_PROTOCOL_VERSION, icon=ICON_PROTOCOL_VERSION, ) @@ -119,7 +113,7 @@ class MinecraftServerLatencySensor(MinecraftServerSensorEntity): """Initialize latency sensor.""" super().__init__( server=server, - type_name=NAME_LATENCY, + entity_type=KEY_LATENCY, icon=ICON_LATENCY, unit=UnitOfTime.MILLISECONDS, ) @@ -138,7 +132,7 @@ class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): """Initialize online players sensor.""" super().__init__( server=server, - type_name=NAME_PLAYERS_ONLINE, + entity_type=KEY_PLAYERS_ONLINE, icon=ICON_PLAYERS_ONLINE, unit=UNIT_PLAYERS_ONLINE, ) @@ -165,7 +159,7 @@ class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): """Initialize maximum number of players sensor.""" super().__init__( server=server, - type_name=NAME_PLAYERS_MAX, + entity_type=KEY_PLAYERS_MAX, icon=ICON_PLAYERS_MAX, unit=UNIT_PLAYERS_MAX, ) @@ -184,7 +178,7 @@ class MinecraftServerMOTDSensor(MinecraftServerSensorEntity): """Initialize MOTD sensor.""" super().__init__( server=server, - type_name=NAME_MOTD, + entity_type=KEY_MOTD, icon=ICON_MOTD, ) diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index b4d68bc6117..b64c96f580b 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -12,11 +12,7 @@ }, "error": { "invalid_port": "Port must be in range from 1024 to 65535. Please correct it and try again.", - "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server.", - "invalid_ip": "IP address is invalid (MAC address could not be determined). Please correct it and try again." - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server." } }, "entity": { diff --git a/requirements_all.txt b/requirements_all.txt index 021e7da2c91..5ba5612db12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -865,7 +865,6 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.dlna_dmr # homeassistant.components.kef -# homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 352737e583f..62f6bdd2334 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -681,7 +681,6 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.dlna_dmr # homeassistant.components.kef -# homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 3a201f15bf3..d9e7d46a88c 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -11,12 +11,10 @@ from homeassistant.components.minecraft_server.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - class QueryMock: """Mock for result of aiodns.DNSResolver.query.""" @@ -82,47 +80,6 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_invalid_ip(hass: HomeAssistant) -> None: - """Test error in case of an invalid IP address.""" - with patch("getmac.get_mac_address", return_value=None): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_ip"} - - -async def test_same_host(hass: HomeAssistant) -> None: - """Test abort in case of same host name.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), - ): - unique_id = "mc.dummyserver.com-25565" - config_data = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com", - CONF_PORT: DEFAULT_PORT, - } - mock_config_entry = MockConfigEntry( - domain=DOMAIN, unique_id=unique_id, data=config_data - ) - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_port_too_small(hass: HomeAssistant) -> None: """Test error in case of a too small port.""" with patch( From dfee5d06a6a8531c948a4602220629c9f76cffa2 Mon Sep 17 00:00:00 2001 From: Pawel Date: Thu, 7 Sep 2023 12:45:31 +0200 Subject: [PATCH 234/984] Add support for more busy codes for Epson (#99771) add support for more busy codes --- homeassistant/components/epson/manifest.json | 2 +- homeassistant/components/epson/media_player.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/epson/manifest.json b/homeassistant/components/epson/manifest.json index 77a1a89b686..7b8f8d8a4a2 100644 --- a/homeassistant/components/epson/manifest.json +++ b/homeassistant/components/epson/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/epson", "iot_class": "local_polling", "loggers": ["epson_projector"], - "requirements": ["epson-projector==0.5.0"] + "requirements": ["epson-projector==0.5.1"] } diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 5c49f566bb5..1f80be9fe06 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -6,7 +6,7 @@ import logging from epson_projector import Projector, ProjectorUnavailableError from epson_projector.const import ( BACK, - BUSY, + BUSY_CODES, CMODE, CMODE_LIST, CMODE_LIST_SET, @@ -147,7 +147,7 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity): self._attr_volume_level = float(volume) except ValueError: self._attr_volume_level = None - elif power_state == BUSY: + elif power_state in BUSY_CODES: self._attr_state = MediaPlayerState.ON else: self._attr_state = MediaPlayerState.OFF diff --git a/requirements_all.txt b/requirements_all.txt index 5ba5612db12..89c4cff5d3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -753,7 +753,7 @@ env-canada==0.5.36 ephem==4.1.2 # homeassistant.components.epson -epson-projector==0.5.0 +epson-projector==0.5.1 # homeassistant.components.epsonworkforce epsonprinter==0.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62f6bdd2334..6d59aa4493f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -606,7 +606,7 @@ env-canada==0.5.36 ephem==4.1.2 # homeassistant.components.epson -epson-projector==0.5.0 +epson-projector==0.5.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 From 306c7cd9a94694d9e1ccd88775a930b6cdd8a1ff Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 12:45:47 +0200 Subject: [PATCH 235/984] Use correct config entry id in Livisi (#99812) --- homeassistant/components/livisi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index b0387c6dcc9..e638c84a917 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -33,7 +33,7 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator device_registry = dr.async_get(hass) device_registry.async_get_or_create( - config_entry_id=coordinator.serial_number, + config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Livisi", name=f"SHC {coordinator.controller_type} {coordinator.serial_number}", From eee5705458bdbfd8c7b3cea3cc09ee21dec30ffb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Sep 2023 13:00:26 +0200 Subject: [PATCH 236/984] Fix typo in TrackTemplateResultInfo (#99809) --- homeassistant/helpers/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 1f74de497e2..76e73401beb 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1197,7 +1197,7 @@ class TrackTemplateResultInfo: ) _LOGGER.debug( ( - "Template group %s listens for %s, re-render blocker by super" + "Template group %s listens for %s, re-render blocked by super" " template: %s" ), self._track_templates, From 368acaf6fd0e42ea5bdeb58c4855d417ce98faf6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Sep 2023 13:33:38 +0200 Subject: [PATCH 237/984] Improve error handling in /api/states POST (#99810) --- homeassistant/components/api/__init__.py | 23 ++++++++++++++++++----- tests/components/api/test_init.py | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 10cf63b701d..6aead6e109f 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -30,7 +30,13 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized +from homeassistant.exceptions import ( + InvalidEntityFormatError, + InvalidStateError, + ServiceNotFound, + TemplateError, + Unauthorized, +) from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions @@ -236,7 +242,7 @@ class APIEntityStateView(HomeAssistantView): """Update state of entity.""" if not request["hass_user"].is_admin: raise Unauthorized(entity_id=entity_id) - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] try: data = await request.json() except ValueError: @@ -251,9 +257,16 @@ class APIEntityStateView(HomeAssistantView): is_new_state = hass.states.get(entity_id) is None # Write state - hass.states.async_set( - entity_id, new_state, attributes, force_update, self.context(request) - ) + try: + hass.states.async_set( + entity_id, new_state, attributes, force_update, self.context(request) + ) + except InvalidEntityFormatError: + return self.json_message( + "Invalid entity ID specified.", HTTPStatus.BAD_REQUEST + ) + except InvalidStateError: + return self.json_message("Invalid state specified.", HTTPStatus.BAD_REQUEST) # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 38528b335b0..2d570540341 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -97,6 +97,28 @@ async def test_api_state_change_of_non_existing_entity( assert hass.states.get("test_entity.that_does_not_exist").state == new_state +async def test_api_state_change_with_bad_entity_id( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if we omit state.""" + resp = await mock_api_client.post( + "/api/states/bad.entity.id", json={"state": "new_state"} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + +async def test_api_state_change_with_bad_state( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if we omit state.""" + resp = await mock_api_client.post( + "/api/states/test.test", json={"state": "x" * 256} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + async def test_api_state_change_with_bad_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: From 8a4cd913b83f90ece6ae66482f0fd41baf55d319 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 13:53:44 +0200 Subject: [PATCH 238/984] Use shorthand attributes in Hisense (#99355) --- .../components/hisense_aehw4a1/climate.py | 166 ++++++------------ 1 file changed, 58 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 113a0c622b9..ca5ec694eab 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -145,23 +145,19 @@ class ClimateAehW4a1(ClimateEntity): | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.PRESET_MODE ) + _attr_fan_modes = FAN_MODES + _attr_swing_modes = SWING_MODES + _attr_preset_modes = PRESET_MODES + _attr_available = False + _attr_target_temperature_step = 1 + _previous_state: HVACMode | str | None = None + _on: str | None = None def __init__(self, device): """Initialize the climate device.""" - self._unique_id = device + self._attr_unique_id = device + self._attr_name = device self._device = AehW4a1(device) - self._fan_modes = FAN_MODES - self._swing_modes = SWING_MODES - self._preset_modes = PRESET_MODES - self._attr_available = False - self._on = None - self._current_temperature = None - self._target_temperature = None - self._attr_hvac_mode = None - self._fan_mode = None - self._swing_mode = None - self._preset_mode = None - self._previous_state = None async def async_update(self) -> None: """Pull state from AEH-W4A1.""" @@ -169,7 +165,7 @@ class ClimateAehW4a1(ClimateEntity): status = await self._device.command("status_102_0") except pyaehw4a1.exceptions.ConnectionError as library_error: _LOGGER.warning( - "Unexpected error of %s: %s", self._unique_id, library_error + "Unexpected error of %s: %s", self._attr_unique_id, library_error ) self._attr_available = False return @@ -180,123 +176,65 @@ class ClimateAehW4a1(ClimateEntity): if status["temperature_Fahrenheit"] == "0": self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_min_temp = MIN_TEMP_C + self._attr_max_temp = MAX_TEMP_C else: self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_min_temp = MIN_TEMP_F + self._attr_max_temp = MAX_TEMP_F - self._current_temperature = int(status["indoor_temperature_status"], 2) + self._attr_current_temperature = int(status["indoor_temperature_status"], 2) if self._on == "1": device_mode = status["mode_status"] self._attr_hvac_mode = AC_TO_HA_STATE[device_mode] fan_mode = status["wind_status"] - self._fan_mode = AC_TO_HA_FAN_MODES[fan_mode] + self._attr_fan_mode = AC_TO_HA_FAN_MODES[fan_mode] swing_mode = f'{status["up_down"]}{status["left_right"]}' - self._swing_mode = AC_TO_HA_SWING[swing_mode] + self._attr_swing_mode = AC_TO_HA_SWING[swing_mode] if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT): - self._target_temperature = int(status["indoor_temperature_setting"], 2) + self._attr_target_temperature = int( + status["indoor_temperature_setting"], 2 + ) else: - self._target_temperature = None + self._attr_target_temperature = None if status["efficient"] == "1": - self._preset_mode = PRESET_BOOST + self._attr_preset_mode = PRESET_BOOST elif status["low_electricity"] == "1": - self._preset_mode = PRESET_ECO + self._attr_preset_mode = PRESET_ECO elif status["sleep_status"] == "0000001": - self._preset_mode = PRESET_SLEEP + self._attr_preset_mode = PRESET_SLEEP elif status["sleep_status"] == "0000010": - self._preset_mode = "sleep_2" + self._attr_preset_mode = "sleep_2" elif status["sleep_status"] == "0000011": - self._preset_mode = "sleep_3" + self._attr_preset_mode = "sleep_3" elif status["sleep_status"] == "0000100": - self._preset_mode = "sleep_4" + self._attr_preset_mode = "sleep_4" else: - self._preset_mode = PRESET_NONE + self._attr_preset_mode = PRESET_NONE else: self._attr_hvac_mode = HVACMode.OFF - self._fan_mode = None - self._swing_mode = None - self._target_temperature = None - self._preset_mode = None - - @property - def name(self): - """Return the name of the climate device.""" - return self._unique_id - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we are trying to reach.""" - return self._target_temperature - - @property - def fan_mode(self): - """Return the fan setting.""" - return self._fan_mode - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return self._fan_modes - - @property - def preset_mode(self): - """Return the preset mode if on.""" - return self._preset_mode - - @property - def preset_modes(self): - """Return the list of available preset modes.""" - return self._preset_modes - - @property - def swing_mode(self): - """Return swing operation.""" - return self._swing_mode - - @property - def swing_modes(self): - """Return the list of available fan modes.""" - return self._swing_modes - - @property - def min_temp(self): - """Return the minimum temperature.""" - if self.temperature_unit == UnitOfTemperature.CELSIUS: - return MIN_TEMP_C - return MIN_TEMP_F - - @property - def max_temp(self): - """Return the maximum temperature.""" - if self.temperature_unit == UnitOfTemperature.CELSIUS: - return MAX_TEMP_C - return MAX_TEMP_F - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return 1 + self._attr_fan_mode = None + self._attr_swing_mode = None + self._attr_target_temperature = None + self._attr_preset_mode = None async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if self._on != "1": _LOGGER.warning( - "AC at %s is off, could not set temperature", self._unique_id + "AC at %s is off, could not set temperature", self._attr_unique_id ) return if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - _LOGGER.debug("Setting temp of %s to %s", self._unique_id, temp) - if self._preset_mode != PRESET_NONE: + _LOGGER.debug("Setting temp of %s to %s", self._attr_unique_id, temp) + if self._attr_preset_mode != PRESET_NONE: await self.async_set_preset_mode(PRESET_NONE) - if self.temperature_unit == UnitOfTemperature.CELSIUS: + if self._attr_temperature_unit == UnitOfTemperature.CELSIUS: await self._device.command(f"temp_{int(temp)}_C") else: await self._device.command(f"temp_{int(temp)}_F") @@ -304,24 +242,30 @@ class ClimateAehW4a1(ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" if self._on != "1": - _LOGGER.warning("AC at %s is off, could not set fan mode", self._unique_id) + _LOGGER.warning( + "AC at %s is off, could not set fan mode", self._attr_unique_id + ) return if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.FAN_ONLY) and ( self._attr_hvac_mode != HVACMode.FAN_ONLY or fan_mode != FAN_AUTO ): - _LOGGER.debug("Setting fan mode of %s to %s", self._unique_id, fan_mode) + _LOGGER.debug( + "Setting fan mode of %s to %s", self._attr_unique_id, fan_mode + ) await self._device.command(HA_FAN_MODES_TO_AC[fan_mode]) async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" if self._on != "1": _LOGGER.warning( - "AC at %s is off, could not set swing mode", self._unique_id + "AC at %s is off, could not set swing mode", self._attr_unique_id ) return - _LOGGER.debug("Setting swing mode of %s to %s", self._unique_id, swing_mode) - swing_act = self._swing_mode + _LOGGER.debug( + "Setting swing mode of %s to %s", self._attr_unique_id, swing_mode + ) + swing_act = self._attr_swing_mode if swing_mode == SWING_OFF and swing_act != SWING_OFF: if swing_act in (SWING_HORIZONTAL, SWING_BOTH): @@ -354,7 +298,9 @@ class ClimateAehW4a1(ClimateEntity): return await self.async_turn_on() - _LOGGER.debug("Setting preset mode of %s to %s", self._unique_id, preset_mode) + _LOGGER.debug( + "Setting preset mode of %s to %s", self._attr_unique_id, preset_mode + ) if preset_mode == PRESET_ECO: await self._device.command("energysave_on") @@ -379,13 +325,17 @@ class ClimateAehW4a1(ClimateEntity): await self._device.command("energysave_off") elif self._previous_state == PRESET_BOOST: await self._device.command("turbo_off") - elif self._previous_state in HA_STATE_TO_AC: + elif self._previous_state in HA_STATE_TO_AC and isinstance( + self._previous_state, HVACMode + ): await self._device.command(HA_STATE_TO_AC[self._previous_state]) self._previous_state = None async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - _LOGGER.debug("Setting operation mode of %s to %s", self._unique_id, hvac_mode) + _LOGGER.debug( + "Setting operation mode of %s to %s", self._attr_unique_id, hvac_mode + ) if hvac_mode == HVACMode.OFF: await self.async_turn_off() else: @@ -395,10 +345,10 @@ class ClimateAehW4a1(ClimateEntity): async def async_turn_on(self) -> None: """Turn on.""" - _LOGGER.debug("Turning %s on", self._unique_id) + _LOGGER.debug("Turning %s on", self._attr_unique_id) await self._device.command("on") async def async_turn_off(self) -> None: """Turn off.""" - _LOGGER.debug("Turning %s off", self._unique_id) + _LOGGER.debug("Turning %s off", self._attr_unique_id) await self._device.command("off") From 40a3d97230a7d85ff4338d719fc6676caa938fbd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 13:55:16 +0200 Subject: [PATCH 239/984] Use shorthand attributes in Plex (#99769) Co-authored-by: Robert Resch --- homeassistant/components/plex/button.py | 12 ++++-------- homeassistant/components/plex/media_player.py | 7 ++++--- homeassistant/components/plex/sensor.py | 9 +++++---- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 58e0b78560b..985b4ccb4e9 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -38,17 +38,13 @@ class PlexScanClientsButton(ButtonEntity): self.server_id = server_id self._attr_name = f"Scan Clients ({server_name})" self._attr_unique_id = f"plex-scan_clients-{self.server_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, server_id)}, + manufacturer="Plex", + ) async def async_press(self) -> None: """Press the button.""" async_dispatcher_send( self.hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(self.server_id) ) - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - identifiers={(DOMAIN, self.server_id)}, - manufacturer="Plex", - ) diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 23f2895fd51..3e6875f98b9 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -117,6 +117,10 @@ def _async_add_entities(hass, registry, async_add_entities, server_id, new_entit class PlexMediaPlayer(MediaPlayerEntity): """Representation of a Plex device.""" + _attr_available = False + _attr_should_poll = False + _attr_state = MediaPlayerState.IDLE + def __init__(self, plex_server, device, player_source, session=None): """Initialize the Plex device.""" self.plex_server = plex_server @@ -136,9 +140,6 @@ class PlexMediaPlayer(MediaPlayerEntity): self._volume_level = 1 # since we can't retrieve remotely self._volume_muted = False # since we can't retrieve remotely - self._attr_available = False - self._attr_should_poll = False - self._attr_state = MediaPlayerState.IDLE self._attr_unique_id = ( f"{self.plex_server.machine_identifier}:{self.machine_identifier}" ) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index a705d11cb41..972cd8d4bc9 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -129,6 +129,11 @@ class PlexSensor(SensorEntity): class PlexLibrarySectionSensor(SensorEntity): """Representation of a Plex library section sensor.""" + _attr_available = True + _attr_entity_registry_enabled_default = False + _attr_should_poll = False + _attr_native_unit_of_measurement = "Items" + def __init__(self, hass, plex_server, plex_library_section): """Initialize the sensor.""" self._server = plex_server @@ -137,14 +142,10 @@ class PlexLibrarySectionSensor(SensorEntity): self.library_section = plex_library_section self.library_type = plex_library_section.type - self._attr_available = True - self._attr_entity_registry_enabled_default = False self._attr_extra_state_attributes = {} self._attr_icon = LIBRARY_ICON_LOOKUP.get(self.library_type, "mdi:plex") self._attr_name = f"{self.server_name} Library - {plex_library_section.title}" - self._attr_should_poll = False self._attr_unique_id = f"library-{self.server_id}-{plex_library_section.uuid}" - self._attr_native_unit_of_measurement = "Items" async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" From c9a1836d45bebde1522979abeee0a07245c9cbfd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 7 Sep 2023 14:54:56 +0200 Subject: [PATCH 240/984] Update coverage to 7.3.1 (#99805) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4095d6732c9..ba636c56649 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==2.15.4 -coverage==7.3.0 +coverage==7.3.1 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 From 0d6f202eb34e923d0cbdec443a21f4b28891ad63 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Thu, 7 Sep 2023 15:26:57 +0200 Subject: [PATCH 241/984] Change AVM FRITZ!Box Call monitor sensor into an enum (#99762) Co-authored-by: Joost Lekkerkerker Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .../components/fritzbox_callmonitor/sensor.py | 5 ++++- .../components/fritzbox_callmonitor/strings.json | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 43cdb29f85f..11c3166fd88 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -12,7 +12,7 @@ from typing import Any, cast from fritzconnection.core.fritzmonitor import FritzMonitor -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant @@ -82,6 +82,9 @@ class FritzBoxCallSensor(SensorEntity): """Implementation of a Fritz!Box call monitor.""" _attr_icon = ICON_PHONE + _attr_translation_key = DOMAIN + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = list(CallState) def __init__( self, diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 6b2fa2943f9..89f049bfbe9 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -37,5 +37,17 @@ "error": { "malformed_prefixes": "Prefixes are malformed, please check their format." } + }, + "entity": { + "sensor": { + "fritzbox_callmonitor": { + "state": { + "ringing": "Ringing", + "dialing": "Dialing", + "talking": "Talking", + "idle": "[%key:common::state::idle%]" + } + } + } } } From 526b5871709a90b7f7a3af8bf58dbfa482825060 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 15:32:03 +0200 Subject: [PATCH 242/984] Remove unused variable from rainbird (#99824) --- homeassistant/components/rainbird/switch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index ac42e00c676..39bb4a7b0d1 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -71,7 +71,6 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) else: self._attr_name = None self._attr_has_entity_name = True - self._state = None self._duration_minutes = duration_minutes self._attr_unique_id = f"{coordinator.serial_number}-{zone}" self._attr_device_info = DeviceInfo( From 1fe17b5bedce6421a57f53ed0b82c27eb69a47f0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 15:56:21 +0200 Subject: [PATCH 243/984] Use shorthand attributes in Sense (#99833) --- .../components/sense/binary_sensor.py | 46 ++++--------------- 1 file changed, 8 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 2aee20be5ae..094ecbdfcf7 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -74,53 +74,23 @@ class SenseDevice(BinarySensorEntity): _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_available = False + _attr_device_class = BinarySensorDeviceClass.POWER def __init__(self, sense_devices_data, device, sense_monitor_id): """Initialize the Sense binary sensor.""" - self._name = device["name"] + self._attr_name = device["name"] self._id = device["id"] self._sense_monitor_id = sense_monitor_id - self._unique_id = f"{sense_monitor_id}-{self._id}" - self._icon = sense_to_mdi(device["icon"]) + self._attr_unique_id = f"{sense_monitor_id}-{self._id}" + self._attr_icon = sense_to_mdi(device["icon"]) self._sense_devices_data = sense_devices_data - self._state = None - self._available = False - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def available(self): - """Return the availability of the binary sensor.""" - return self._available - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique id of the binary sensor.""" - return self._unique_id @property def old_unique_id(self): """Return the old not so unique id of the binary sensor.""" return self._id - @property - def icon(self): - """Return the icon of the binary sensor.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return BinarySensorDeviceClass.POWER - async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( @@ -135,8 +105,8 @@ class SenseDevice(BinarySensorEntity): def _async_update_from_data(self): """Get the latest data, update state. Must not do I/O.""" new_state = bool(self._sense_devices_data.get_device_by_id(self._id)) - if self._available and self._state == new_state: + if self._attr_available and self._attr_is_on == new_state: return - self._available = True - self._state = new_state + self._attr_available = True + self._attr_is_on = new_state self.async_write_ha_state() From 114b5bd1f005a7b43dd9256f934dafeec7ddb347 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 15:56:40 +0200 Subject: [PATCH 244/984] Use shorthand attributes in Roomba (#99831) --- homeassistant/components/roomba/binary_sensor.py | 7 +------ homeassistant/components/roomba/braava.py | 14 +++----------- homeassistant/components/roomba/irobot_base.py | 12 ++---------- homeassistant/components/roomba/roomba.py | 11 ++--------- 4 files changed, 8 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index f480839388c..cd37e089c9f 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -27,7 +27,7 @@ async def async_setup_entry( class RoombaBinStatus(IRobotEntity, BinarySensorEntity): """Class to hold Roomba Sensor basic info.""" - ICON = "mdi:delete-variant" + _attr_icon = "mdi:delete-variant" _attr_translation_key = "bin_full" @property @@ -35,11 +35,6 @@ class RoombaBinStatus(IRobotEntity, BinarySensorEntity): """Return the ID of this sensor.""" return f"bin_{self._blid}" - @property - def icon(self): - """Return the icon of this sensor.""" - return self.ICON - @property def is_on(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py index ea08829cba6..db517a065ea 100644 --- a/homeassistant/components/roomba/braava.py +++ b/homeassistant/components/roomba/braava.py @@ -29,6 +29,8 @@ SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED class BraavaJet(IRobotVacuum): """Braava Jet.""" + _attr_supported_features = SUPPORT_BRAAVA + def __init__(self, roomba, blid): """Initialize the Roomba handler.""" super().__init__(roomba, blid) @@ -38,12 +40,7 @@ class BraavaJet(IRobotVacuum): for behavior in BRAAVA_MOP_BEHAVIORS: for spray in BRAAVA_SPRAY_AMOUNT: speed_list.append(f"{behavior}-{spray}") - self._speed_list = speed_list - - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_BRAAVA + self._attr_fan_speed_list = speed_list @property def fan_speed(self): @@ -62,11 +59,6 @@ class BraavaJet(IRobotVacuum): pad_wetness_value = pad_wetness.get("disposable") return f"{behavior}-{pad_wetness_value}" - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return self._speed_list - async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" try: diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 8b909392250..a48b3638608 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -138,17 +138,14 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): """Base class for iRobot robots.""" _attr_name = None + _attr_supported_features = SUPPORT_IROBOT + _attr_available = True # Always available, otherwise setup will fail def __init__(self, roomba, blid): """Initialize the iRobot handler.""" super().__init__(roomba, blid) self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_IROBOT - @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" @@ -159,11 +156,6 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): """Return the state of the vacuum cleaner.""" return self._robot_state - @property - def available(self) -> bool: - """Return True if entity is available.""" - return True # Always available, otherwise setup will fail - @property def extra_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py index 7cac9a3ba52..2c50508a637 100644 --- a/homeassistant/components/roomba/roomba.py +++ b/homeassistant/components/roomba/roomba.py @@ -42,10 +42,8 @@ class RoombaVacuum(IRobotVacuum): class RoombaVacuumCarpetBoost(RoombaVacuum): """Roomba robot with carpet boost.""" - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_ROOMBA_CARPET_BOOST + _attr_fan_speed_list = FAN_SPEEDS + _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST @property def fan_speed(self): @@ -62,11 +60,6 @@ class RoombaVacuumCarpetBoost(RoombaVacuum): fan_speed = FAN_SPEED_ECO return fan_speed - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return FAN_SPEEDS - async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" if fan_speed.capitalize() in FAN_SPEEDS: From 2a3ebbc26cb4f09d561d5bde71ef420297b6744e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 16:08:44 +0200 Subject: [PATCH 245/984] Use shorthand attributes in SharkIQ (#99836) --- homeassistant/components/sharkiq/vacuum.py | 23 ++++++++-------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 8c6c4a9197a..9510b7d3f66 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -88,7 +88,13 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum super().__init__(coordinator) self.sharkiq = sharkiq self._attr_unique_id = sharkiq.serial_number - self._serial_number = sharkiq.serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, sharkiq.serial_number)}, + manufacturer=SHARK, + model=self.model, + name=sharkiq.name, + sw_version=sharkiq.get_property_value(Properties.ROBOT_FIRMWARE_VERSION), + ) def clean_spot(self, **kwargs: Any) -> None: """Clean a spot. Not yet implemented.""" @@ -106,7 +112,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum @property def is_online(self) -> bool: """Tell us if the device is online.""" - return self.coordinator.device_is_online(self._serial_number) + return self.coordinator.device_is_online(self.sharkiq.serial_number) @property def model(self) -> str: @@ -115,19 +121,6 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum return self.sharkiq.vac_model_number return self.sharkiq.oem_model_number - @property - def device_info(self) -> DeviceInfo: - """Device info dictionary.""" - return DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, - manufacturer=SHARK, - model=self.model, - name=self.sharkiq.name, - sw_version=self.sharkiq.get_property_value( - Properties.ROBOT_FIRMWARE_VERSION - ), - ) - @property def error_code(self) -> int | None: """Return the last observed error code (or None).""" From d9b48b03f70b832a149d0778fbd64c643e9690aa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 16:20:57 +0200 Subject: [PATCH 246/984] Use shorthand attributes in Rachio (#99823) --- .../components/rachio/binary_sensor.py | 22 ++--- homeassistant/components/rachio/switch.py | 84 ++++++------------- 2 files changed, 32 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 029b1bac6e3..652806a2bad 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -59,16 +59,6 @@ class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): _attr_has_entity_name = True - def __init__(self, controller): - """Set up a new Rachio controller binary sensor.""" - super().__init__(controller) - self._state = None - - @property - def is_on(self) -> bool: - """Return whether the sensor has a 'true' value.""" - return self._state - @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -98,15 +88,15 @@ class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" if args[0][0][KEY_SUBTYPE] in (SUBTYPE_ONLINE, SUBTYPE_COLD_REBOOT): - self._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE + self._attr_is_on = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE self.async_on_remove( async_dispatcher_connect( @@ -132,15 +122,15 @@ class RachioRainSensor(RachioControllerBinarySensor): def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_ON: - self._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED] + self._attr_is_on = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED] self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 0557a2bdb19..bbb08f6d46f 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -178,16 +178,6 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent class RachioSwitch(RachioDevice, SwitchEntity): """Represent a Rachio state that can be toggled.""" - def __init__(self, controller): - """Initialize a new Rachio switch.""" - super().__init__(controller) - self._state = None - - @property - def is_on(self) -> bool: - """Return whether the switch is currently on.""" - return self._state - @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -219,9 +209,9 @@ class RachioStandbySwitch(RachioSwitch): def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" if args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON: - self._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() @@ -236,7 +226,7 @@ class RachioStandbySwitch(RachioSwitch): async def async_added_to_hass(self) -> None: """Subscribe to updates.""" if KEY_ON in self._controller.init_data: - self._state = not self._controller.init_data[KEY_ON] + self._attr_is_on = not self._controller.init_data[KEY_ON] self.async_on_remove( async_dispatcher_connect( @@ -274,20 +264,20 @@ class RachioRainDelay(RachioSwitch): if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_DELAY_ON: endtime = parse_datetime(args[0][0][KEY_RAIN_DELAY_END]) _LOGGER.debug("Rain delay expires at %s", endtime) - self._state = True + self._attr_is_on = True assert endtime is not None self._cancel_update = async_track_point_in_utc_time( self.hass, self._delay_expiration, endtime ) elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_DELAY_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() @callback def _delay_expiration(self, *args) -> None: """Trigger when a rain delay expires.""" - self._state = False + self._attr_is_on = False self._cancel_update = None self.async_write_ha_state() @@ -304,12 +294,12 @@ class RachioRainDelay(RachioSwitch): async def async_added_to_hass(self) -> None: """Subscribe to updates.""" if KEY_RAIN_DELAY in self._controller.init_data: - self._state = self._controller.init_data[ + self._attr_is_on = self._controller.init_data[ KEY_RAIN_DELAY ] / 1000 > as_timestamp(now()) # If the controller was in a rain delay state during a reboot, this re-sets the timer - if self._state is True: + if self._attr_is_on is True: delay_end = utc_from_timestamp( self._controller.init_data[KEY_RAIN_DELAY] / 1000 ) @@ -330,19 +320,22 @@ class RachioRainDelay(RachioSwitch): class RachioZone(RachioSwitch): """Representation of one zone of sprinklers connected to the Rachio Iro.""" + _attr_icon = "mdi:water" + def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Zone.""" self.id = data[KEY_ID] - self._zone_name = data[KEY_NAME] + self._attr_name = data[KEY_NAME] self._zone_number = data[KEY_ZONE_NUMBER] self._zone_enabled = data[KEY_ENABLED] - self._entity_picture = data.get(KEY_IMAGE_URL) + self._attr_entity_picture = data.get(KEY_IMAGE_URL) self._person = person self._shade_type = data.get(KEY_CUSTOM_SHADE, {}).get(KEY_NAME) self._zone_type = data.get(KEY_CUSTOM_CROP, {}).get(KEY_NAME) self._slope_type = data.get(KEY_CUSTOM_SLOPE, {}).get(KEY_NAME) self._summary = "" self._current_schedule = current_schedule + self._attr_unique_id = f"{controller.controller_id}-zone-{self.id}" super().__init__(controller) def __str__(self): @@ -354,31 +347,11 @@ class RachioZone(RachioSwitch): """How the Rachio API refers to the zone.""" return self.id - @property - def name(self) -> str: - """Return the friendly name of the zone.""" - return self._zone_name - - @property - def unique_id(self) -> str: - """Return a unique id by combining controller id and zone number.""" - return f"{self._controller.controller_id}-zone-{self.zone_id}" - - @property - def icon(self) -> str: - """Return the icon to display.""" - return "mdi:water" - @property def zone_is_enabled(self) -> bool: """Return whether the zone is allowed to run.""" return self._zone_enabled - @property - def entity_picture(self): - """Return the entity picture to use in the frontend, if any.""" - return self._entity_picture - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" @@ -424,7 +397,7 @@ class RachioZone(RachioSwitch): def set_moisture_percent(self, percent) -> None: """Set the zone moisture percent.""" - _LOGGER.debug("Setting %s moisture to %s percent", self._zone_name, percent) + _LOGGER.debug("Setting %s moisture to %s percent", self.name, percent) self._controller.rachio.zone.set_moisture_percent(self.id, percent / 100) @callback @@ -436,19 +409,19 @@ class RachioZone(RachioSwitch): self._summary = args[0][KEY_SUMMARY] if args[0][KEY_SUBTYPE] == SUBTYPE_ZONE_STARTED: - self._state = True + self._attr_is_on = True elif args[0][KEY_SUBTYPE] in [ SUBTYPE_ZONE_STOPPED, SUBTYPE_ZONE_COMPLETED, SUBTYPE_ZONE_PAUSED, ]: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) + self._attr_is_on = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) self.async_on_remove( async_dispatcher_connect( @@ -463,24 +436,17 @@ class RachioSchedule(RachioSwitch): def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Schedule.""" self._schedule_id = data[KEY_ID] - self._schedule_name = data[KEY_NAME] self._duration = data[KEY_DURATION] self._schedule_enabled = data[KEY_ENABLED] self._summary = data[KEY_SUMMARY] self.type = data.get(KEY_TYPE, SCHEDULE_TYPE_FIXED) self._current_schedule = current_schedule + self._attr_unique_id = ( + f"{controller.controller_id}-schedule-{self._schedule_id}" + ) + self._attr_name = f"{data[KEY_NAME]} Schedule" super().__init__(controller) - @property - def name(self) -> str: - """Return the friendly name of the schedule.""" - return f"{self._schedule_name} Schedule" - - @property - def unique_id(self) -> str: - """Return a unique id by combining controller id and schedule.""" - return f"{self._controller.controller_id}-schedule-{self._schedule_id}" - @property def icon(self) -> str: """Return the icon to display.""" @@ -521,18 +487,20 @@ class RachioSchedule(RachioSwitch): with suppress(KeyError): if args[0][KEY_SCHEDULE_ID] == self._schedule_id: if args[0][KEY_SUBTYPE] in [SUBTYPE_SCHEDULE_STARTED]: - self._state = True + self._attr_is_on = True elif args[0][KEY_SUBTYPE] in [ SUBTYPE_SCHEDULE_STOPPED, SUBTYPE_SCHEDULE_COMPLETED, ]: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) + self._attr_is_on = self._schedule_id == self._current_schedule.get( + KEY_SCHEDULE_ID + ) self.async_on_remove( async_dispatcher_connect( From 73651dbffd6fac6ba2aa68f08f0cba1db3cc7e18 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 16:37:30 +0200 Subject: [PATCH 247/984] Use shorthand attributes in Snapcast (#99840) --- .../components/snapcast/media_player.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9dadae2e3e2..f0b6eccf8b4 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -160,7 +160,7 @@ class SnapcastGroupDevice(MediaPlayerEntity): self._attr_available = True self._group = group self._entry_id = entry_id - self._uid = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" + self._attr_unique_id = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" async def async_added_to_hass(self) -> None: """Subscribe to group events.""" @@ -184,11 +184,6 @@ class SnapcastGroupDevice(MediaPlayerEntity): return MediaPlayerState.IDLE return STREAM_STATUS.get(self._group.stream_status) - @property - def unique_id(self): - """Return the ID of snapcast group.""" - return self._uid - @property def identifier(self): """Return the snapcast identifier.""" @@ -260,7 +255,8 @@ class SnapcastClientDevice(MediaPlayerEntity): """Initialize the Snapcast client device.""" self._attr_available = True self._client = client - self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" + # Note: Host part is needed, when using multiple snapservers + self._attr_unique_id = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" self._entry_id = entry_id async def async_added_to_hass(self) -> None: @@ -278,14 +274,6 @@ class SnapcastClientDevice(MediaPlayerEntity): self._attr_available = available self.schedule_update_ha_state() - @property - def unique_id(self): - """Return the ID of this snapcast client. - - Note: Host part is needed, when using multiple snapservers - """ - return self._uid - @property def identifier(self): """Return the snapcast identifier.""" From 69f6a115b6689b7862d7c2fda10eb3ef699305df Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 17:28:13 +0200 Subject: [PATCH 248/984] Move shorthand attributes out of constructor in Sensibo (#99834) Use shorthand attributes in Sensibo --- homeassistant/components/sensibo/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index da86ba8fe24..3529627b497 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -180,6 +180,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Representation of a Sensibo device.""" _attr_name = None + _attr_precision = PRECISION_TENTHS + _attr_translation_key = "climate_device" def __init__( self, coordinator: SensiboDataUpdateCoordinator, device_id: str @@ -193,8 +195,6 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): else UnitOfTemperature.FAHRENHEIT ) self._attr_supported_features = self.get_features() - self._attr_precision = PRECISION_TENTHS - self._attr_translation_key = "climate_device" def get_features(self) -> ClimateEntityFeature: """Get supported features.""" From c3e14d051431a7bdfef365bb5af270a7fcde30ee Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 7 Sep 2023 17:28:50 +0200 Subject: [PATCH 249/984] Fix Freebox Home battery sensor (#99756) --- homeassistant/components/freebox/const.py | 3 +++ tests/components/freebox/const.py | 6 ++--- tests/components/freebox/test_sensor.py | 28 ++++++++++++++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 59dce75649b..5bed7b3456a 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -85,4 +85,7 @@ CATEGORY_TO_MODEL = { HOME_COMPATIBLE_CATEGORIES = [ FreeboxHomeCategory.CAMERA, + FreeboxHomeCategory.DWS, + FreeboxHomeCategory.KFB, + FreeboxHomeCategory.PIR, ] diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index a6253dbf315..0b58348a5df 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -1986,7 +1986,7 @@ DATA_HOME_GET_NODES = [ "category": "kfb", "group": {"label": ""}, "id": 9, - "label": "Télécommande I", + "label": "Télécommande", "name": "node_9", "props": { "Address": 5, @@ -2067,7 +2067,7 @@ DATA_HOME_GET_NODES = [ "category": "dws", "group": {"label": "Entrée"}, "id": 11, - "label": "dws i", + "label": "Ouverture porte", "name": "node_11", "props": { "Address": 6, @@ -2259,7 +2259,7 @@ DATA_HOME_GET_NODES = [ "category": "pir", "group": {"label": "Salon"}, "id": 26, - "label": "Salon Détecteur s", + "label": "Détecteur", "name": "node_26", "props": { "Address": 9, diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py index 2ebcf8baa04..41daa79fe4e 100644 --- a/tests/components/freebox/test_sensor.py +++ b/tests/components/freebox/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_STORAGE_GET_DISKS +from .const import DATA_HOME_GET_NODES, DATA_STORAGE_GET_DISKS from tests.common import async_fire_time_changed @@ -43,3 +43,29 @@ async def test_disk( # To execute the save await hass.async_block_till_done() assert hass.states.get("sensor.freebox_free_space").state == "44.9" + + +async def test_battery( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test battery sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.telecommande_niveau_de_batterie").state == "100" + assert hass.states.get("sensor.ouverture_porte_niveau_de_batterie").state == "100" + assert hass.states.get("sensor.detecteur_niveau_de_batterie").state == "100" + + # Simulate a changed battery + data_home_get_nodes_changed = deepcopy(DATA_HOME_GET_NODES) + data_home_get_nodes_changed[2]["show_endpoints"][3]["value"] = 25 + data_home_get_nodes_changed[3]["show_endpoints"][3]["value"] = 50 + data_home_get_nodes_changed[4]["show_endpoints"][3]["value"] = 75 + router().home.get_home_nodes.return_value = data_home_get_nodes_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.telecommande_niveau_de_batterie").state == "25" + assert hass.states.get("sensor.ouverture_porte_niveau_de_batterie").state == "50" + assert hass.states.get("sensor.detecteur_niveau_de_batterie").state == "75" From c567a2c3d4b036839057888621cdb687dc58c480 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 17:36:07 +0200 Subject: [PATCH 250/984] Move unit of temperature to descriptions in Sensibo (#99835) --- homeassistant/components/sensibo/sensor.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 7208902456e..547504d7889 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -107,6 +107,7 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = ( SensiboMotionSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", value_fn=lambda data: data.temperature, @@ -145,6 +146,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( key="feels_like", translation_key="feels_like", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.feelslike, extra_fn=None, @@ -154,6 +156,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( key="climate_react_low", translation_key="climate_react_low", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.smart_low_temp_threshold, extra_fn=lambda data: data.smart_low_state, @@ -163,6 +166,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( key="climate_react_high", translation_key="climate_react_high", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.smart_high_temp_threshold, extra_fn=lambda data: data.smart_high_state, @@ -299,13 +303,6 @@ class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): self.entity_description = entity_description self._attr_unique_id = f"{sensor_id}-{entity_description.key}" - @property - def native_unit_of_measurement(self) -> str | None: - """Add native unit of measurement.""" - if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: - return UnitOfTemperature.CELSIUS - return self.entity_description.native_unit_of_measurement - @property def native_value(self) -> StateType: """Return value of sensor.""" @@ -333,13 +330,6 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, SensorEntity): self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" - @property - def native_unit_of_measurement(self) -> str | None: - """Add native unit of measurement.""" - if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: - return UnitOfTemperature.CELSIUS - return self.entity_description.native_unit_of_measurement - @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" From 94aec3e590c026176c101fcf5653dd35024d7aa3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 18:30:58 +0200 Subject: [PATCH 251/984] Use shorthand attributes in Opentherm gateway (#99630) --- .../components/opentherm_gw/binary_sensor.py | 60 ++++---------- .../components/opentherm_gw/climate.py | 81 ++++++------------- .../components/opentherm_gw/sensor.py | 65 ++++----------- 3 files changed, 55 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 7f2a05ddf03..d6aa5a3b700 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -52,6 +52,7 @@ class OpenThermBinarySensor(BinarySensorEntity): """Represent an OpenTherm Gateway binary sensor.""" _attr_should_poll = False + _attr_entity_registry_enabled_default = False def __init__(self, gw_dev, var, source, device_class, friendly_name_format): """Initialize the binary sensor.""" @@ -61,73 +62,42 @@ class OpenThermBinarySensor(BinarySensorEntity): self._gateway = gw_dev self._var = var self._source = source - self._state = None - self._device_class = device_class + self._attr_device_class = device_class if TRANSLATE_SOURCE[source] is not None: friendly_name_format = ( f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" ) - self._friendly_name = friendly_name_format.format(gw_dev.name) + self._attr_name = friendly_name_format.format(gw_dev.name) self._unsub_updates = None + self._attr_unique_id = f"{gw_dev.gw_id}-{source}-{var}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) async def async_added_to_hass(self) -> None: """Subscribe to updates from the component.""" - _LOGGER.debug("Added OpenTherm Gateway binary sensor %s", self._friendly_name) + _LOGGER.debug("Added OpenTherm Gateway binary sensor %s", self._attr_name) self._unsub_updates = async_dispatcher_connect( self.hass, self._gateway.update_signal, self.receive_report ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from updates from the component.""" - _LOGGER.debug( - "Removing OpenTherm Gateway binary sensor %s", self._friendly_name - ) + _LOGGER.debug("Removing OpenTherm Gateway binary sensor %s", self._attr_name) self._unsub_updates() @property def available(self): """Return availability of the sensor.""" - return self._state is not None - - @property - def entity_registry_enabled_default(self): - """Disable binary_sensors by default.""" - return False + return self._attr_is_on is not None @callback def receive_report(self, status): """Handle status updates from the component.""" state = status[self._source].get(self._var) - self._state = None if state is None else bool(state) + self._attr_is_on = None if state is None else bool(state) self.async_write_ha_state() - - @property - def name(self): - """Return the friendly name.""" - return self._friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._source}-{self._var}" - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this device.""" - return self._device_class diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index b34239c933a..bcad621eb82 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -70,6 +70,20 @@ class OpenThermClimate(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_available = False + _attr_hvac_modes = [] + _attr_preset_modes = [] + _attr_min_temp = 1 + _attr_max_temp = 30 + _hvac_mode = HVACMode.HEAT + _current_temperature: float | None = None + _new_target_temperature: float | None = None + _target_temperature: float | None = None + _away_mode_a: int | None = None + _away_mode_b: int | None = None + _away_state_a = False + _away_state_b = False + _current_operation: HVACAction | None = None def __init__(self, gw_dev, options): """Initialize the device.""" @@ -78,22 +92,21 @@ class OpenThermClimate(ClimateEntity): ENTITY_ID_FORMAT, gw_dev.gw_id, hass=gw_dev.hass ) self.friendly_name = gw_dev.name + self._attr_name = self.friendly_name self.floor_temp = options.get(CONF_FLOOR_TEMP, DEFAULT_FLOOR_TEMP) self.temp_read_precision = options.get(CONF_READ_PRECISION) self.temp_set_precision = options.get(CONF_SET_PRECISION) self.temporary_ovrd_mode = options.get(CONF_TEMPORARY_OVRD_MODE, True) - self._available = False - self._current_operation: HVACAction | None = None - self._current_temperature = None - self._hvac_mode = HVACMode.HEAT - self._new_target_temperature = None - self._target_temperature = None - self._away_mode_a = None - self._away_mode_b = None - self._away_state_a = False - self._away_state_b = False self._unsub_options = None self._unsub_updates = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) + self._attr_unique_id = gw_dev.gw_id @callback def update_options(self, entry): @@ -123,7 +136,7 @@ class OpenThermClimate(ClimateEntity): @callback def receive_report(self, status): """Receive and handle a new report from the Gateway.""" - self._available = status != gw_vars.DEFAULT_STATUS + self._attr_available = status != gw_vars.DEFAULT_STATUS ch_active = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_CH_ACTIVE) flame_on = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_FLAME_ON) cooling_active = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_COOLING_ACTIVE) @@ -171,32 +184,6 @@ class OpenThermClimate(ClimateEntity): ) self.async_write_ha_state() - @property - def available(self): - """Return availability of the sensor.""" - return self._available - - @property - def name(self): - """Return the friendly name.""" - return self.friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return self._gateway.gw_id - @property def precision(self): """Return the precision of the system.""" @@ -216,11 +203,6 @@ class OpenThermClimate(ClimateEntity): """Return current HVAC mode.""" return self._hvac_mode - @property - def hvac_modes(self) -> list[HVACMode]: - """Return available HVAC modes.""" - return [] - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode.""" _LOGGER.warning("Changing HVAC mode is not supported") @@ -259,11 +241,6 @@ class OpenThermClimate(ClimateEntity): return PRESET_AWAY return PRESET_NONE - @property - def preset_modes(self): - """Available preset modes to set.""" - return [] - def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" _LOGGER.warning("Changing preset mode is not supported") @@ -278,13 +255,3 @@ class OpenThermClimate(ClimateEntity): temp, self.temporary_ovrd_mode ) self.async_write_ha_state() - - @property - def min_temp(self): - """Return the minimum temperature.""" - return 1 - - @property - def max_temp(self): - """Return the maximum temperature.""" - return 30 diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index df9260d7d19..09fbb0ef6ee 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -49,6 +49,7 @@ class OpenThermSensor(SensorEntity): """Representation of an OpenTherm Gateway sensor.""" _attr_should_poll = False + _attr_entity_registry_enabled_default = False def __init__(self, gw_dev, var, source, device_class, unit, friendly_name_format): """Initialize the OpenTherm Gateway sensor.""" @@ -58,37 +59,39 @@ class OpenThermSensor(SensorEntity): self._gateway = gw_dev self._var = var self._source = source - self._value = None - self._device_class = device_class - self._unit = unit + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = unit if TRANSLATE_SOURCE[source] is not None: friendly_name_format = ( f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" ) - self._friendly_name = friendly_name_format.format(gw_dev.name) + self._attr_name = friendly_name_format.format(gw_dev.name) self._unsub_updates = None + self._attr_unique_id = f"{gw_dev.gw_id}-{source}-{var}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) async def async_added_to_hass(self) -> None: """Subscribe to updates from the component.""" - _LOGGER.debug("Added OpenTherm Gateway sensor %s", self._friendly_name) + _LOGGER.debug("Added OpenTherm Gateway sensor %s", self._attr_name) self._unsub_updates = async_dispatcher_connect( self.hass, self._gateway.update_signal, self.receive_report ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from updates from the component.""" - _LOGGER.debug("Removing OpenTherm Gateway sensor %s", self._friendly_name) + _LOGGER.debug("Removing OpenTherm Gateway sensor %s", self._attr_name) self._unsub_updates() @property def available(self): """Return availability of the sensor.""" - return self._value is not None - - @property - def entity_registry_enabled_default(self): - """Disable sensors by default.""" - return False + return self._attr_native_value is not None @callback def receive_report(self, status): @@ -96,41 +99,5 @@ class OpenThermSensor(SensorEntity): value = status[self._source].get(self._var) if isinstance(value, float): value = f"{value:2.1f}" - self._value = value + self._attr_native_value = value self.async_write_ha_state() - - @property - def name(self): - """Return the friendly name of the sensor.""" - return self._friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._source}-{self._var}" - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def native_value(self): - """Return the state of the device.""" - return self._value - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit From e8c4ddf05cdba5c1487fde3731833f4c2e9d84c9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 7 Sep 2023 13:22:24 -0400 Subject: [PATCH 252/984] Bump ZHA dependencies (#99855) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 7352487a318..cce223fac11 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.2", + "bellows==0.36.3", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.103", diff --git a/requirements_all.txt b/requirements_all.txt index 89c4cff5d3d..3b2bdf99c66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,7 +510,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.2 +bellows==0.36.3 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d59aa4493f..bb37e4285b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -431,7 +431,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.2 +bellows==0.36.3 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 From dcd00546ba1ff49534fdd3db4f0d653f9e786c12 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 19:47:56 +0200 Subject: [PATCH 253/984] Use shorthand attributes in Sonarr (#99844) --- homeassistant/components/sonarr/entity.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index d73b9d852c8..6231ca3903a 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -24,15 +24,11 @@ class SonarrEntity(CoordinatorEntity[SonarrDataUpdateCoordinator[SonarrDataT]]): self.coordinator = coordinator self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about the application.""" - return DeviceInfo( - configuration_url=self.coordinator.host_configuration.base_url, + self._attr_device_info = DeviceInfo( + configuration_url=coordinator.host_configuration.base_url, entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer=DEFAULT_NAME, name=DEFAULT_NAME, - sw_version=self.coordinator.system_version, + sw_version=coordinator.system_version, ) From a00cbe2677bf6a3927be3e35ce876a3820584ec0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 19:49:18 +0200 Subject: [PATCH 254/984] Move shorthand attributes out of Snooz constructor (#99842) --- homeassistant/components/snooz/fan.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index c5b3e5b5b69..5cb80cb4189 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -74,15 +74,15 @@ class SnoozFan(FanEntity, RestoreEntity): _attr_has_entity_name = True _attr_name = None + _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_should_poll = False + _is_on: bool | None = None + _percentage: int | None = None def __init__(self, data: SnoozConfigurationData) -> None: """Initialize a Snooz fan entity.""" self._device = data.device self._attr_unique_id = data.device.address - self._attr_supported_features = FanEntityFeature.SET_SPEED - self._attr_should_poll = False - self._is_on: bool | None = None - self._percentage: int | None = None self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, data.device.address)}) @callback From 02e077daab5c7c01548d4db590889f5baa04e47a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 19:51:35 +0200 Subject: [PATCH 255/984] Use shorthand attributes in Ring (#99829) --- homeassistant/components/ring/camera.py | 6 +----- homeassistant/components/ring/entity.py | 16 ++++++---------- homeassistant/components/ring/light.py | 18 ++++-------------- homeassistant/components/ring/sensor.py | 9 +++------ homeassistant/components/ring/switch.py | 24 +++++------------------- 5 files changed, 19 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 0b3f1509b18..7f897d17203 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -60,6 +60,7 @@ class RingCam(RingEntityMixin, Camera): self._video_url = None self._image = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL + self._attr_unique_id = device.id async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -91,11 +92,6 @@ class RingCam(RingEntityMixin, Camera): self._expires_at = dt_util.utcnow() self.async_write_ha_state() - @property - def unique_id(self): - """Return a unique ID.""" - return self._device.id - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 2b345b3b703..7160d2ef725 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -19,6 +19,12 @@ class RingEntityMixin(Entity): self._config_entry_id = config_entry_id self._device = device self._attr_extra_state_attributes = {} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Ring", + model=device.model, + name=device.name, + ) async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -37,13 +43,3 @@ class RingEntityMixin(Entity): def ring_objects(self): """Return the Ring API objects.""" return self.hass.data[DOMAIN][self._config_entry_id] - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device.device_id)}, - manufacturer="Ring", - model=self._device.model, - name=self._device.name, - ) diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 2604e557b79..93640e2764e 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -55,8 +55,8 @@ class RingLight(RingEntityMixin, LightEntity): def __init__(self, config_entry_id, device): """Initialize the light.""" super().__init__(config_entry_id, device) - self._unique_id = device.id - self._light_on = device.lights == ON_STATE + self._attr_unique_id = device.id + self._attr_is_on = device.lights == ON_STATE self._no_updates_until = dt_util.utcnow() @callback @@ -65,19 +65,9 @@ class RingLight(RingEntityMixin, LightEntity): if self._no_updates_until > dt_util.utcnow(): return - self._light_on = self._device.lights == ON_STATE + self._attr_is_on = self._device.lights == ON_STATE self.async_write_ha_state() - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def is_on(self): - """If the switch is currently on or off.""" - return self._light_on - def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" try: @@ -86,7 +76,7 @@ class RingLight(RingEntityMixin, LightEntity): _LOGGER.error("Time out setting %s light to %s", self.entity_id, new_state) return - self._light_on = new_state == ON_STATE + self._attr_is_on = new_state == ON_STATE self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.async_write_ha_state() diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index fbaeb8a4b5b..af23af07eba 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -68,6 +68,9 @@ class RingSensor(RingEntityMixin, SensorEntity): class HealthDataRingSensor(RingSensor): """Ring sensor that relies on health data.""" + # These sensors are data hungry and not useful. Disable by default. + _attr_entity_registry_enabled_default = False + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() @@ -89,12 +92,6 @@ class HealthDataRingSensor(RingSensor): """Call update method.""" self.async_write_ha_state() - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - # These sensors are data hungry and not useful. Disable by default. - return False - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 43bd303577a..7069acd5f0f 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -50,24 +50,20 @@ class BaseRingSwitch(RingEntityMixin, SwitchEntity): """Initialize the switch.""" super().__init__(config_entry_id, device) self._device_type = device_type - self._unique_id = f"{self._device.id}-{self._device_type}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id + self._attr_unique_id = f"{self._device.id}-{self._device_type}" class SirenSwitch(BaseRingSwitch): """Creates a switch to turn the ring cameras siren on and off.""" _attr_translation_key = "siren" + _attr_icon = SIREN_ICON def __init__(self, config_entry_id, device): """Initialize the switch for a device with a siren.""" super().__init__(config_entry_id, device, "siren") self._no_updates_until = dt_util.utcnow() - self._siren_on = device.siren > 0 + self._attr_is_on = device.siren > 0 @callback def _update_callback(self): @@ -75,7 +71,7 @@ class SirenSwitch(BaseRingSwitch): if self._no_updates_until > dt_util.utcnow(): return - self._siren_on = self._device.siren > 0 + self._attr_is_on = self._device.siren > 0 self.async_write_ha_state() def _set_switch(self, new_state): @@ -86,15 +82,10 @@ class SirenSwitch(BaseRingSwitch): _LOGGER.error("Time out setting %s siren to %s", self.entity_id, new_state) return - self._siren_on = new_state > 0 + self._attr_is_on = new_state > 0 self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.schedule_update_ha_state() - @property - def is_on(self): - """If the switch is currently on or off.""" - return self._siren_on - def turn_on(self, **kwargs: Any) -> None: """Turn the siren on for 30 seconds.""" self._set_switch(1) @@ -102,8 +93,3 @@ class SirenSwitch(BaseRingSwitch): def turn_off(self, **kwargs: Any) -> None: """Turn the siren off.""" self._set_switch(0) - - @property - def icon(self): - """Return the icon.""" - return SIREN_ICON From 66d16108be0b92984c0436c76df3813b41ad878a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 19:52:12 +0200 Subject: [PATCH 256/984] Use shorthand attributes in Rainforest eagle (#99825) --- .../components/rainforest_eagle/sensor.py | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 113cfceb7d6..987142c6390 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -75,11 +75,13 @@ class EagleSensor(CoordinatorEntity[EagleDataCoordinator], SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = entity_description - - @property - def unique_id(self) -> str | None: - """Return unique ID of entity.""" - return f"{self.coordinator.cloud_id}-${self.coordinator.hardware_address}-{self.entity_description.key}" + self._attr_unique_id = f"{coordinator.cloud_id}-${coordinator.hardware_address}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.cloud_id)}, + manufacturer="Rainforest Automation", + model=coordinator.model, + name=coordinator.model, + ) @property def available(self) -> bool: @@ -90,13 +92,3 @@ class EagleSensor(CoordinatorEntity[EagleDataCoordinator], SensorEntity): def native_value(self) -> StateType: """Return native value of the sensor.""" return self.coordinator.data.get(self.entity_description.key) - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.cloud_id)}, - manufacturer="Rainforest Automation", - model=self.coordinator.model, - name=self.coordinator.model, - ) From 4017473d51631972c272d405a561353a2f09ad36 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 20:00:43 +0200 Subject: [PATCH 257/984] Use str instead of string placeholders in solaredge (#99843) --- homeassistant/components/solaredge/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index e1ea7960086..f2c073c6918 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -353,7 +353,7 @@ class SolarEdgeDetailsSensor(SolarEdgeSensorEntity): """Return a unique ID.""" if not self.data_service.site_id: return None - return f"{self.data_service.site_id}" + return str(self.data_service.site_id) class SolarEdgeInventorySensor(SolarEdgeSensorEntity): From c68d96cf092a9f5e1b71805173dcb5d420ae628b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 13:25:29 -0500 Subject: [PATCH 258/984] Bump zeroconf to 0.99.0 (#99853) --- 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 117744a2775..4f736866fd9 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.98.0"] + "requirements": ["zeroconf==0.99.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0624415b11c..452ac9eae28 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.98.0 +zeroconf==0.99.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 3b2bdf99c66..09beeec4194 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2767,7 +2767,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.98.0 +zeroconf==0.99.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb37e4285b8..91b151904d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2040,7 +2040,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.98.0 +zeroconf==0.99.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 54bd7c9af0ce4a9086986590bbe8ae70e6917273 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 13:27:29 -0500 Subject: [PATCH 259/984] Bump dbus-fast to 1.95.2 (#99852) --- 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 bcb371971a6..4231e03c2ef 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.11.0", - "dbus-fast==1.95.0" + "dbus-fast==1.95.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 452ac9eae28..6b5ed1dc9f9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.95.0 +dbus-fast==1.95.2 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index 09beeec4194..db110087d19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.0 +dbus-fast==1.95.2 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91b151904d5..7817932dedf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.0 +dbus-fast==1.95.2 # homeassistant.components.debugpy debugpy==1.6.7 From 0dc8e8dabef34ebea5fa9e5434d2a08301ffcf34 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 7 Sep 2023 20:34:23 +0200 Subject: [PATCH 260/984] Add device class and UoM in Sensibo Number entities (#99861) * device class and uom number platform * icons --- homeassistant/components/sensibo/number.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 94765a17a4d..d4e268ea44d 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -7,9 +7,13 @@ from typing import Any from pysensibo.model import SensiboDevice -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 +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,8 +43,9 @@ DEVICE_NUMBER_TYPES = ( SensiboNumberEntityDescription( key="calibration_temp", translation_key="calibration_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, remote_key="temperature", - icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, native_min_value=-10, @@ -51,8 +56,9 @@ DEVICE_NUMBER_TYPES = ( SensiboNumberEntityDescription( key="calibration_hum", translation_key="calibration_humidity", + device_class=NumberDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, remote_key="humidity", - icon="mdi:water", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, native_min_value=-10, From 1c27a0339d97af2da570fac441f62d6e12f4e6de Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Thu, 7 Sep 2023 20:37:14 +0200 Subject: [PATCH 261/984] Renson fan (#94495) * Add fan feature * Changed order of platform * Use super()._handle_coordinator_update() * format file * Set _attr_has_entity_name * Cleanup Fan code * Refresh after setting ventilation speed + translation * remove unused translation key --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 1 + homeassistant/components/renson/fan.py | 118 ++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 homeassistant/components/renson/fan.py diff --git a/.coveragerc b/.coveragerc index c72400392b7..d9cb511e86e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1009,6 +1009,7 @@ omit = homeassistant/components/renson/const.py homeassistant/components/renson/entity.py homeassistant/components/renson/sensor.py + homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 86dfdc1f18b..dbc0468a11a 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.FAN, Platform.SENSOR, ] diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py new file mode 100644 index 00000000000..0fe639d40ec --- /dev/null +++ b/homeassistant/components/renson/fan.py @@ -0,0 +1,118 @@ +"""Platform to control a Renson ventilation unit.""" +from __future__ import annotations + +import logging +import math +from typing import Any + +from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType +from renson_endura_delta.renson import Level, RensonVentilation + +from homeassistant.components.fan import FanEntity, FanEntityFeature +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 . import RensonCoordinator +from .const import DOMAIN +from .entity import RensonEntity + +_LOGGER = logging.getLogger(__name__) + +CMD_MAPPING = { + 0: Level.HOLIDAY, + 1: Level.LEVEL1, + 2: Level.LEVEL2, + 3: Level.LEVEL3, + 4: Level.LEVEL4, +} + +SPEED_MAPPING = { + Level.OFF.value: 0, + Level.HOLIDAY.value: 0, + Level.LEVEL1.value: 1, + Level.LEVEL2.value: 2, + Level.LEVEL3.value: 3, + Level.LEVEL4.value: 4, +} + + +SPEED_RANGE: tuple[float, float] = (1, 4) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson fan platform.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + async_add_entities([RensonFan(api, coordinator)]) + + +class RensonFan(RensonEntity, FanEntity): + """Representation of the Renson fan platform.""" + + _attr_icon = "mdi:air-conditioner" + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = FanEntityFeature.SET_SPEED + + def __init__(self, api: RensonVentilation, coordinator: RensonCoordinator) -> None: + """Initialize the Renson fan.""" + super().__init__("fan", api, coordinator) + self._attr_speed_count = int_states_in_range(SPEED_RANGE) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + level = self.api.parse_value( + self.api.get_field_value(self.coordinator.data, CURRENT_LEVEL_FIELD.name), + DataType.LEVEL, + ) + + self._attr_percentage = ranged_value_to_percentage( + SPEED_RANGE, SPEED_MAPPING[level] + ) + + super()._handle_coordinator_update() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is None: + percentage = 1 + + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan (to away).""" + await self.async_set_percentage(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set fan speed percentage.""" + _LOGGER.debug("Changing fan speed percentage to %s", percentage) + + if percentage == 0: + cmd = Level.HOLIDAY + else: + speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + cmd = CMD_MAPPING[speed] + + await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) + + await self.coordinator.async_request_refresh() From 77180a73b7bbc6aa2b1d9b7b063a1cc7be38763c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 7 Sep 2023 20:56:00 +0200 Subject: [PATCH 262/984] Modbus scale parameter cuts decimals (#99758) --- .../components/modbus/base_platform.py | 2 ++ tests/components/modbus/test_sensor.py | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index b71f8c20215..672250790da 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -160,6 +160,8 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] + if self._scale < 1 and not self._precision: + self._precision = 2 self._offset = config[CONF_OFFSET] self._slave_count = config.get(CONF_SLAVE_COUNT, 0) self._slave_size = self._count = config[CONF_COUNT] diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index a746bcda3ba..551398c898b 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -596,6 +596,38 @@ async def test_config_wrong_struct_sensor( False, "1.23", ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 10, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + }, + [0x00AB, 0xCDEF], + False, + "112593750", + ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 0.01, + CONF_OFFSET: 0, + CONF_PRECISION: 2, + }, + [0x00AB, 0xCDEF], + False, + "112593.75", + ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 0.01, + CONF_OFFSET: 0, + }, + [0x00AB, 0xCDEF], + False, + "112593.75", + ), ], ) async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: From 4ce9c1f304739861a4289f92d84172719ff3e69d Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 7 Sep 2023 15:27:41 -0400 Subject: [PATCH 263/984] Add Diagnostic platform to Aladdin Connect (#99682) * Add diagnostics platform * Add diagnostic platform * Add raw data to diagnostics * Remove config data bump aioaladdinconnect, use new doors property for diag * remove unnecessary component config refactor diag output --- .../components/aladdin_connect/diagnostics.py | 29 ++++++++++++++ .../components/aladdin_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aladdin_connect/conftest.py | 6 ++- .../snapshots/test_diagnostics.ambr | 20 ++++++++++ .../aladdin_connect/test_diagnostics.py | 40 +++++++++++++++++++ 7 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/aladdin_connect/diagnostics.py create mode 100644 tests/components/aladdin_connect/snapshots/test_diagnostics.ambr create mode 100644 tests/components/aladdin_connect/test_diagnostics.py diff --git a/homeassistant/components/aladdin_connect/diagnostics.py b/homeassistant/components/aladdin_connect/diagnostics.py new file mode 100644 index 00000000000..c49d321631e --- /dev/null +++ b/homeassistant/components/aladdin_connect/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Aladdin Connect.""" +from __future__ import annotations + +from typing import Any + +from AIOAladdinConnect import AladdinConnectClient + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {"serial", "device_id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] + + diagnostics_data = { + "doors": async_redact_data(acc.doors, TO_REDACT), + } + + return diagnostics_data diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 3f31a833f1a..83f8e0167e8 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], - "requirements": ["AIOAladdinConnect==0.1.57"] + "requirements": ["AIOAladdinConnect==0.1.58"] } diff --git a/requirements_all.txt b/requirements_all.txt index db110087d19..024443e8cf5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,7 +5,7 @@ AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.57 +AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell AIOSomecomfort==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7817932dedf..4c468519570 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.57 +AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell AIOSomecomfort==0.0.17 diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py index 250548e7ef2..3f5fc4f8f97 100644 --- a/tests/components/aladdin_connect/conftest.py +++ b/tests/components/aladdin_connect/conftest.py @@ -12,6 +12,10 @@ DEVICE_CONFIG_OPEN = { "link_status": "Connected", "serial": "12345", "model": "02", + "rssi": -67, + "ble_strength": 0, + "vendor": "GENIE", + "battery_level": 0, } @@ -35,7 +39,7 @@ def fixture_mock_aladdinconnect_api(): mock_opener.async_get_ble_strength = AsyncMock(return_value="-45") mock_opener.get_ble_strength.return_value = "-45" mock_opener.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) - + mock_opener.doors = [DEVICE_CONFIG_OPEN] mock_opener.register_callback = mock.Mock(return_value=True) mock_opener.open_door = AsyncMock(return_value=True) mock_opener.close_door = AsyncMock(return_value=True) diff --git a/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr b/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8f96567a49f --- /dev/null +++ b/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'doors': list([ + dict({ + 'battery_level': 0, + 'ble_strength': 0, + 'device_id': '**REDACTED**', + 'door_number': 1, + 'link_status': 'Connected', + 'model': '02', + 'name': 'home', + 'rssi': -67, + 'serial': '**REDACTED**', + 'status': 'open', + 'vendor': 'GENIE', + }), + ]), + }) +# --- diff --git a/tests/components/aladdin_connect/test_diagnostics.py b/tests/components/aladdin_connect/test_diagnostics.py new file mode 100644 index 00000000000..4d5fe903798 --- /dev/null +++ b/tests/components/aladdin_connect/test_diagnostics.py @@ -0,0 +1,40 @@ +"""Test AccuWeather diagnostics.""" +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.aladdin_connect.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +YAML_CONFIG = {"username": "test-user", "password": "test-password"} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_aladdinconnect_api: MagicMock, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot From cd8426152f6c41aa6a003b5b50404b65d6647d7e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 7 Sep 2023 21:49:03 +0200 Subject: [PATCH 264/984] Fix NOAA tides warnings (#99856) --- homeassistant/components/noaa_tides/sensor.py | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index 7f3260c7635..a83f18fd6ca 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import TYPE_CHECKING, Any, Literal, TypedDict import noaa_coops as coops import requests @@ -17,6 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import METRIC_SYSTEM +if TYPE_CHECKING: + from pandas import Timestamp + _LOGGER = logging.getLogger(__name__) CONF_STATION_ID = "station_id" @@ -76,40 +80,56 @@ def setup_platform( add_entities([noaa_sensor], True) +class NOAATidesData(TypedDict): + """Representation of a single tide.""" + + time_stamp: list[Timestamp] + hi_lo: list[Literal["L"] | Literal["H"]] + predicted_wl: list[float] + + class NOAATidesAndCurrentsSensor(SensorEntity): """Representation of a NOAA Tides and Currents sensor.""" _attr_attribution = "Data provided by NOAA" - def __init__(self, name, station_id, timezone, unit_system, station): + def __init__(self, name, station_id, timezone, unit_system, station) -> None: """Initialize the sensor.""" self._name = name self._station_id = station_id self._timezone = timezone self._unit_system = unit_system self._station = station - self.data = None + self.data: NOAATidesData | None = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of this device.""" - attr = {} + attr: dict[str, Any] = {} if self.data is None: return attr if self.data["hi_lo"][1] == "H": - attr["high_tide_time"] = self.data.index[1].strftime("%Y-%m-%dT%H:%M") + attr["high_tide_time"] = self.data["time_stamp"][1].strftime( + "%Y-%m-%dT%H:%M" + ) attr["high_tide_height"] = self.data["predicted_wl"][1] - attr["low_tide_time"] = self.data.index[2].strftime("%Y-%m-%dT%H:%M") + attr["low_tide_time"] = self.data["time_stamp"][2].strftime( + "%Y-%m-%dT%H:%M" + ) attr["low_tide_height"] = self.data["predicted_wl"][2] elif self.data["hi_lo"][1] == "L": - attr["low_tide_time"] = self.data.index[1].strftime("%Y-%m-%dT%H:%M") + attr["low_tide_time"] = self.data["time_stamp"][1].strftime( + "%Y-%m-%dT%H:%M" + ) attr["low_tide_height"] = self.data["predicted_wl"][1] - attr["high_tide_time"] = self.data.index[2].strftime("%Y-%m-%dT%H:%M") + attr["high_tide_time"] = self.data["time_stamp"][2].strftime( + "%Y-%m-%dT%H:%M" + ) attr["high_tide_height"] = self.data["predicted_wl"][2] return attr @@ -118,7 +138,7 @@ class NOAATidesAndCurrentsSensor(SensorEntity): """Return the state of the device.""" if self.data is None: return None - api_time = self.data.index[0] + api_time = self.data["time_stamp"][0] if self.data["hi_lo"][0] == "H": tidetime = api_time.strftime("%-I:%M %p") return f"High tide at {tidetime}" @@ -142,8 +162,13 @@ class NOAATidesAndCurrentsSensor(SensorEntity): units=self._unit_system, time_zone=self._timezone, ) - self.data = df_predictions.head() - _LOGGER.debug("Data = %s", self.data) + api_data = df_predictions.head() + self.data = NOAATidesData( + time_stamp=list(api_data.index), + hi_lo=list(api_data["hi_lo"].values), + predicted_wl=list(api_data["predicted_wl"].values), + ) + _LOGGER.debug("Data = %s", api_data) _LOGGER.debug( "Recent Tide data queried with start time set to %s", begin.strftime("%m-%d-%Y %H:%M"), From 9d5595fd7d852e13829e85cf04cdf020dcb4a136 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 16:08:53 -0500 Subject: [PATCH 265/984] Bump zeroconf to 0.102.0 (#99875) --- 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 4f736866fd9..e97c430d35d 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.99.0"] + "requirements": ["zeroconf==0.102.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b5ed1dc9f9..629e654bb7b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.99.0 +zeroconf==0.102.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 024443e8cf5..7ccc7b0eb9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2767,7 +2767,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.99.0 +zeroconf==0.102.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c468519570..776fe09d99f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2040,7 +2040,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.99.0 +zeroconf==0.102.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From a82cd48282ec43e043697414aa31587b37bdd0eb Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 8 Sep 2023 00:32:15 +0200 Subject: [PATCH 266/984] Bump aiovodafone to 0.1.0 (#99851) * bump aiovodafone to 0.1.0 * fix tests --- homeassistant/components/vodafone_station/coordinator.py | 4 ++-- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vodafone_station/test_config_flow.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index b79acac9ce9..58079180bf8 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -112,9 +112,9 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): dev_info, utc_point_in_time ), ) - for dev_info in (await self.api.get_all_devices()).values() + for dev_info in (await self.api.get_devices_data()).values() } - data_sensors = await self.api.get_user_data() + data_sensors = await self.api.get_sensor_data() await self.api.logout() return UpdateCoordinatorDataType(data_devices, data_sensors) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 7069629ca2e..5470cdd684c 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.0.6"] + "requirements": ["aiovodafone==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7ccc7b0eb9d..a9ff02dfac5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -370,7 +370,7 @@ aiounifi==61 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.0.6 +aiovodafone==0.1.0 # homeassistant.components.waqi aiowaqi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 776fe09d99f..74c38e5d162 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -345,7 +345,7 @@ aiounifi==61 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.0.6 +aiovodafone==0.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 03a1198288d..3d2ef0cf568 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -78,7 +78,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> # Should be recoverable after hits error with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_all_devices", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_devices_data", return_value={ "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", @@ -191,7 +191,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> # Should be recoverable after hits error with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_all_devices", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_devices_data", return_value={ "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", From 5a66aac330e573c2293fea515298590bcafd4bed Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:02:03 +0200 Subject: [PATCH 267/984] Use shorthand attributes in Telldus live (#99887) --- homeassistant/components/tellduslive/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index e15f89888b1..06b505d9574 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -142,6 +142,7 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): def __init__(self, client, device_id): """Initialize TelldusLiveSensor.""" super().__init__(client, device_id) + self._attr_unique_id = "{}-{}-{}".format(*device_id) if desc := SENSOR_TYPES.get(self._type): self.entity_description = desc else: @@ -189,8 +190,3 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): if self._type == SENSOR_TYPE_LUMINANCE: return self._value_as_luminance return self._value - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "{}-{}-{}".format(*self._id) From c2b119bfaf255a042770bd3d85f66e50145b845c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:07:15 +0200 Subject: [PATCH 268/984] Use shorthand attributes in Tp-link Omada (#99889) --- homeassistant/components/tplink_omada/entity.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index bb330ef417a..5008b7e4b18 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -20,14 +20,10 @@ class OmadaDeviceEntity(CoordinatorEntity[OmadaCoordinator[T]], Generic[T]): """Initialize the device.""" super().__init__(coordinator) self.device = device - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, - identifiers={(DOMAIN, (self.device.mac))}, + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, device.mac)}, manufacturer="TP-Link", - model=self.device.model_display_name, - name=self.device.name, + model=device.model_display_name, + name=device.name, ) From 56f05bee91b03eecf710602215b163eccfa883ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:15:34 +0200 Subject: [PATCH 269/984] Use shorthand attributes in Tradfri (#99890) --- .../components/tradfri/base_class.py | 24 +++++++---------- homeassistant/components/tradfri/fan.py | 26 ++++++------------- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index d186e19a2c8..416eb175d31 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -55,7 +55,16 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): self._device_id = self._device.id self._api = handle_error(api) - self._attr_unique_id = f"{self._gateway_id}-{self._device.id}" + info = self._device.device_info + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=info.manufacturer, + model=info.model_number, + name=self._device.name, + sw_version=info.firmware_version, + via_device=(DOMAIN, gateway_id), + ) + self._attr_unique_id = f"{gateway_id}-{self._device_id}" @abstractmethod @callback @@ -71,19 +80,6 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): self._refresh() super()._handle_coordinator_update() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - info = self._device.device_info - return DeviceInfo( - identifiers={(DOMAIN, self._device.id)}, - manufacturer=info.manufacturer, - model=info.model_number, - name=self._device.name, - sw_version=info.firmware_version, - via_device=(DOMAIN, self._gateway_id), - ) - @property def available(self) -> bool: """Return if entity is available.""" diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index a26dfa1d9a0..c41b24a2647 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -56,6 +56,14 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): _attr_name = None _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED + _attr_preset_modes = [ATTR_AUTO] + # These are the steps: + # 0 = Off + # 1 = Preset: Auto mode + # 2 = Min + # ... with step size 1 + # 50 = Max + _attr_speed_count = ATTR_MAX_FAN_STEPS def __init__( self, @@ -77,19 +85,6 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): """Refresh the device.""" self._device_data = self.coordinator.data.air_purifier_control.air_purifiers[0] - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports. - - These are the steps: - 0 = Off - 1 = Preset: Auto mode - 2 = Min - ... with step size 1 - 50 = Max - """ - return ATTR_MAX_FAN_STEPS - @property def is_on(self) -> bool: """Return true if switch is on.""" @@ -97,11 +92,6 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): return False return cast(bool, self._device_data.state) - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return [ATTR_AUTO] - @property def percentage(self) -> int | None: """Return the current speed percentage.""" From a3d6c6192edfb9742dc47915d4695e3713799477 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:15:49 +0200 Subject: [PATCH 270/984] Use shorthand attributes in Tado (#99886) --- homeassistant/components/tado/climate.py | 39 +++++-------------- homeassistant/components/tado/entity.py | 39 ++++++------------- homeassistant/components/tado/water_heater.py | 19 ++------- 3 files changed, 24 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 36a2ab671c9..1193638c10e 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -219,6 +219,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_name = None + _attr_translation_key = DOMAIN + _available = False def __init__( self, @@ -245,22 +247,22 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_type = zone_type self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - - self._attr_translation_key = DOMAIN self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] self._ac_device = zone_type == TYPE_AIR_CONDITIONING - self._supported_hvac_modes = supported_hvac_modes - self._supported_fan_modes = supported_fan_modes + self._attr_hvac_modes = supported_hvac_modes + self._attr_fan_modes = supported_fan_modes self._attr_supported_features = support_flags - self._available = False - self._cur_temp = None self._cur_humidity = None + if self.supported_features & ClimateEntityFeature.SWING_MODE: + self._attr_swing_modes = [ + TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON], + TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF], + ] self._heat_min_temp = heat_min_temp self._heat_max_temp = heat_max_temp @@ -324,14 +326,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): """ return TADO_TO_HA_HVAC_MODE_MAP.get(self._current_tado_hvac_mode, HVACMode.OFF) - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return self._supported_hvac_modes - @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported. @@ -349,11 +343,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO) return None - @property - def fan_modes(self): - """List of available fan modes.""" - return self._supported_fan_modes - def set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) @@ -474,16 +463,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): """Active swing mode for the device.""" return TADO_TO_HA_SWING_MODE_MAP[self._current_tado_swing_mode] - @property - def swing_modes(self): - """Swing modes for the device.""" - if self.supported_features & ClimateEntityFeature.SWING_MODE: - return [ - TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON], - TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF], - ] - return None - @property def extra_state_attributes(self): """Return temperature offset.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index cfc9e5b1e6e..532d784b190 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -17,18 +17,14 @@ class TadoDeviceEntity(Entity): self._device_info = device_info self.device_name = device_info["serialNo"] self.device_id = device_info["shortSerialNo"] - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( configuration_url=f"https://app.tado.com/en/main/settings/rooms-and-devices/device/{self.device_name}", identifiers={(DOMAIN, self.device_id)}, name=self.device_name, manufacturer=DEFAULT_NAME, - sw_version=self._device_info["currentFwVersion"], - model=self._device_info["deviceType"], - via_device=(DOMAIN, self._device_info["serialNo"]), + sw_version=device_info["currentFwVersion"], + model=device_info["deviceType"], + via_device=(DOMAIN, device_info["serialNo"]), ) @@ -43,16 +39,12 @@ class TadoHomeEntity(Entity): super().__init__() self.home_name = tado.home_name self.home_id = tado.home_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( configuration_url="https://app.tado.com", - identifiers={(DOMAIN, self.home_id)}, + identifiers={(DOMAIN, tado.home_id)}, manufacturer=DEFAULT_NAME, model=TADO_HOME, - name=self.home_name, + name=tado.home_name, ) @@ -65,20 +57,13 @@ class TadoZoneEntity(Entity): def __init__(self, zone_name, home_id, zone_id): """Initialize a Tado zone.""" super().__init__() - self._device_zone_id = f"{home_id}_{zone_id}" self.zone_name = zone_name self.zone_id = zone_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( - configuration_url=( - f"https://app.tado.com/en/main/home/zoneV2/{self.zone_id}" - ), - identifiers={(DOMAIN, self._device_zone_id)}, - name=self.zone_name, + self._attr_device_info = DeviceInfo( + configuration_url=(f"https://app.tado.com/en/main/home/zoneV2/{zone_id}"), + identifiers={(DOMAIN, f"{home_id}_{zone_id}")}, + name=zone_name, manufacturer=DEFAULT_NAME, model=TADO_ZONE, - suggested_area=self.zone_name, + suggested_area=zone_name, ) diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 6d17c85c981..b7e68bbb100 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -120,6 +120,8 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """Representation of a Tado water heater.""" _attr_name = None + _attr_operation_list = OPERATION_MODES + _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__( self, @@ -136,7 +138,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): super().__init__(zone_name, tado.home_id, zone_id) self.zone_id = zone_id - self._unique_id = f"{zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_id} {tado.home_id}" self._device_is_active = False @@ -168,11 +170,6 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): ) self._async_update_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property def current_operation(self): """Return current readable operation mode.""" @@ -188,16 +185,6 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """Return true if away mode is on.""" return self._tado_zone_data.is_away - @property - def operation_list(self): - """Return the list of available operation modes (readable).""" - return OPERATION_MODES - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - return UnitOfTemperature.CELSIUS - @property def min_temp(self): """Return the minimum temperature.""" From 9e8a8012dfcfca34d80d475324a91fffe17127d6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:15:58 +0200 Subject: [PATCH 271/984] Use shorthand attributes in Syncthru (#99884) --- homeassistant/components/syncthru/sensor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index c2ad159fb21..f651556bddb 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -109,6 +109,8 @@ class SyncThruMainSensor(SyncThruSensor): the displayed current status message. """ + _attr_entity_registry_enabled_default = False + def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) @@ -126,11 +128,6 @@ class SyncThruMainSensor(SyncThruSensor): "display_text": self.syncthru.device_status_details(), } - @property - def entity_registry_enabled_default(self) -> bool: - """Disable entity by default.""" - return False - class SyncThruTonerSensor(SyncThruSensor): """Implementation of a Samsung Printer toner sensor platform.""" From 4e826f170452129e30af713323eac347d8f08f97 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:16:08 +0200 Subject: [PATCH 272/984] Use shorthand attributes in Syncthing (#99883) --- homeassistant/components/syncthing/sensor.py | 31 ++++++-------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index 0551ae29d2c..c88de91cae0 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -94,19 +94,17 @@ class FolderSensor(SensorEntity): self._folder_label = folder_label self._state = None self._unsub_timer = None - self._version = version self._short_server_id = server_id.split("-")[0] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._short_server_id} {self._folder_id} {self._folder_label}" - - @property - def unique_id(self): - """Return the unique id of the entity.""" - return f"{self._short_server_id}-{self._folder_id}" + self._attr_name = f"{self._short_server_id} {folder_id} {folder_label}" + self._attr_unique_id = f"{self._short_server_id}-{folder_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._server_id)}, + manufacturer="Syncthing Team", + name=f"Syncthing ({syncthing.url})", + sw_version=version, + ) @property def native_value(self): @@ -132,17 +130,6 @@ class FolderSensor(SensorEntity): """Return the state attributes.""" return self._state - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._server_id)}, - manufacturer="Syncthing Team", - name=f"Syncthing ({self._syncthing.url})", - sw_version=self._version, - ) - async def async_update_status(self): """Request folder status and update state.""" try: From 92628ea068849542af29558951878f2d39cd11f7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:16:35 +0200 Subject: [PATCH 273/984] Use shorthand attributes in Starline (#99882) --- homeassistant/components/starline/entity.py | 12 ++---------- homeassistant/components/starline/switch.py | 7 ++----- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index 7eee5e7a7f8..27be5e2aace 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -21,6 +21,8 @@ class StarlineEntity(Entity): self._account = account self._device = device self._key = key + self._attr_unique_id = f"starline-{key}-{device.device_id}" + self._attr_device_info = account.device_info(device) self._unsubscribe_api: Callable | None = None @property @@ -28,16 +30,6 @@ class StarlineEntity(Entity): """Return True if entity is available.""" return self._account.api.available - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return f"starline-{self._key}-{self._device.device_id}" - - @property - def device_info(self): - """Return the device info.""" - return self._account.device_info(self._device) - def update(self): """Read new state data.""" self.schedule_update_ha_state() diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index b254fa8133f..ebe27e29e8c 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -77,6 +77,8 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): entity_description: StarlineSwitchEntityDescription + _attr_assumed_state = True + def __init__( self, account: StarlineAccount, @@ -108,11 +110,6 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): else self.entity_description.icon_off ) - @property - def assumed_state(self): - """Return True if unable to access real state of the entity.""" - return True - @property def is_on(self): """Return True if entity is on.""" From 432894a4015d45a570505d6c3d8bbf14b4b1381d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:20:22 +0200 Subject: [PATCH 274/984] Use shorthand attributes in SRP Energy (#99881) --- homeassistant/components/srp_energy/sensor.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index a7f0f97b636..f6bd470df8a 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -40,12 +40,7 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) """Initialize the SrpEntity class.""" super().__init__(coordinator) self._attr_unique_id = f"{config_entry.entry_id}_total_usage" - self._name = SENSOR_NAME - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{DEFAULT_NAME} {self._name}" + self._attr_name = f"{DEFAULT_NAME} {SENSOR_NAME}" @property def native_value(self) -> float: From b76ba002e2611bb1e51a218d80f70ab7b5af0fc7 Mon Sep 17 00:00:00 2001 From: lymanepp <4195527+lymanepp@users.noreply.github.com> Date: Thu, 7 Sep 2023 22:12:18 -0400 Subject: [PATCH 275/984] Fix missing dew point and humidity in tomorrowio forecasts (#99793) * Fix missing dew point and humidity in tomorrowio forecasts * Add assertion for correct parameters to realtime_and_all_forecasts method --- .../components/tomorrowio/__init__.py | 2 + tests/components/tomorrowio/test_weather.py | 59 ++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 41fa8158624..77675e3f2ec 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -302,6 +302,8 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): [ TMRW_ATTR_TEMPERATURE_LOW, TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_HUMIDITY, TMRW_ATTR_WIND_SPEED, TMRW_ATTR_WIND_DIRECTION, TMRW_ATTR_CONDITION, diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index a6a5e935614..229e62065a6 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -153,9 +153,66 @@ async def test_legacy_config_entry(hass: HomeAssistant) -> None: assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 30 -async def test_v4_weather(hass: HomeAssistant) -> None: +async def test_v4_weather(hass: HomeAssistant, tomorrowio_config_entry_update) -> None: """Test v4 weather data.""" weather_state = await _setup(hass, API_V4_ENTRY_DATA) + + tomorrowio_config_entry_update.assert_called_with( + [ + "temperature", + "humidity", + "pressureSeaLevel", + "windSpeed", + "windDirection", + "weatherCode", + "visibility", + "pollutantO3", + "windGust", + "cloudCover", + "precipitationType", + "pollutantCO", + "mepIndex", + "mepHealthConcern", + "mepPrimaryPollutant", + "cloudBase", + "cloudCeiling", + "cloudCover", + "dewPoint", + "epaIndex", + "epaHealthConcern", + "epaPrimaryPollutant", + "temperatureApparent", + "fireIndex", + "pollutantNO2", + "pollutantO3", + "particulateMatter10", + "particulateMatter25", + "grassIndex", + "treeIndex", + "weedIndex", + "precipitationType", + "pressureSurfaceLevel", + "solarGHI", + "pollutantSO2", + "uvIndex", + "uvHealthConcern", + "windGust", + ], + [ + "temperatureMin", + "temperatureMax", + "dewPoint", + "humidity", + "windSpeed", + "windDirection", + "weatherCode", + "precipitationIntensityAvg", + "precipitationProbability", + ], + nowcast_timestep=60, + location="80.0,80.0", + ) + assert weather_state.state == ATTR_CONDITION_SUNNY assert weather_state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION assert len(weather_state.attributes[ATTR_FORECAST]) == 14 From b2b57c5f87a519af951a9d1ca8777f5ae3517b92 Mon Sep 17 00:00:00 2001 From: Sam Crang Date: Fri, 8 Sep 2023 04:43:47 +0100 Subject: [PATCH 276/984] Allow exporting of `update` domain to Prometheus (#99400) --- .../components/prometheus/__init__.py | 9 ++++ tests/components/prometheus/test_init.py | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 1818f308239..c96ed2e4ed3 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -671,6 +671,15 @@ class PrometheusMetrics: metric.labels(**self._labels(state)).set(self.state_as_number(state)) + def _handle_update(self, state): + metric = self._metric( + "update_state", + self.prometheus_cli.Gauge, + "Update state, indicating if an update is available (0/1)", + ) + value = self.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + class PrometheusView(HomeAssistantView): """Handle Prometheus requests.""" diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 07a666946fb..f24782b98d4 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -24,6 +24,7 @@ from homeassistant.components import ( prometheus, sensor, switch, + update, ) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -572,6 +573,23 @@ async def test_counter(client, counter_entities) -> None: ) +@pytest.mark.parametrize("namespace", [""]) +async def test_update(client, update_entities) -> None: + """Test prometheus metrics for update.""" + body = await generate_latest_metrics(client) + + assert ( + 'update_state{domain="update",' + 'entity="update.firmware",' + 'friendly_name="Firmware"} 1.0' in body + ) + assert ( + 'update_state{domain="update",' + 'entity="update.addon",' + 'friendly_name="Addon"} 0.0' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_renaming_entity_name( hass: HomeAssistant, @@ -1591,6 +1609,36 @@ async def counter_fixture( return data +@pytest.fixture(name="update_entities") +async def update_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: + """Simulate update entities.""" + data = {} + update_1 = entity_registry.async_get_or_create( + domain=update.DOMAIN, + platform="test", + unique_id="update_1", + suggested_object_id="firmware", + original_name="Firmware", + ) + set_state_with_entry(hass, update_1, STATE_ON) + data["update_1"] = update_1 + + update_2 = entity_registry.async_get_or_create( + domain=update.DOMAIN, + platform="test", + unique_id="update_2", + suggested_object_id="addon", + original_name="Addon", + ) + set_state_with_entry(hass, update_2, STATE_OFF) + data["update_2"] = update_2 + + await hass.async_block_till_done() + return data + + def set_state_with_entry( hass: HomeAssistant, entry: er.RegistryEntry, From b2c3d959119e1ab8a03bc26782d7ece9a8485063 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 11:33:59 +0200 Subject: [PATCH 277/984] Use shorthand attributes in UPB (#99892) --- homeassistant/components/upb/light.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py index 4a71789423f..50e6d50bb4c 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -57,7 +57,7 @@ class UpbLight(UpbAttachedEntity, LightEntity): def __init__(self, element, unique_id, upb): """Initialize an UpbLight.""" super().__init__(element, unique_id, upb) - self._brightness = self._element.status + self._attr_brightness: int = self._element.status @property def color_mode(self) -> ColorMode: @@ -78,15 +78,10 @@ class UpbLight(UpbAttachedEntity, LightEntity): return LightEntityFeature.TRANSITION | LightEntityFeature.FLASH return LightEntityFeature.FLASH - @property - def brightness(self): - """Get the brightness.""" - return self._brightness - @property def is_on(self) -> bool: """Get the current brightness.""" - return self._brightness != 0 + return self._attr_brightness != 0 async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -123,4 +118,4 @@ class UpbLight(UpbAttachedEntity, LightEntity): def _element_changed(self, element, changeset): status = self._element.status - self._brightness = round(status * 2.55) if status else 0 + self._attr_brightness = round(status * 2.55) if status else 0 From 0cf32e74d620d396e75c48dcaa16056da69d1621 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 12:36:46 +0200 Subject: [PATCH 278/984] Use shorthand attributes in Tp-link (#99888) --- homeassistant/components/tplink/entity.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 890793b898d..afb341b47ed 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -41,18 +41,14 @@ class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): super().__init__(coordinator) self.device: SmartDevice = device self._attr_unique_id = self.device.device_id - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, - identifiers={(DOMAIN, str(self.device.device_id))}, + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, str(device.device_id))}, manufacturer="TP-Link", - model=self.device.model, - name=self.device.alias, - sw_version=self.device.hw_info["sw_ver"], - hw_version=self.device.hw_info["hw_ver"], + model=device.model, + name=device.alias, + sw_version=device.hw_info["sw_ver"], + hw_version=device.hw_info["hw_ver"], ) @property From 47a75cc064b6d4a95fd231568d855cd9f901f91f Mon Sep 17 00:00:00 2001 From: Ali Yousuf Date: Fri, 8 Sep 2023 07:07:33 -0400 Subject: [PATCH 279/984] Add more options to Islamic Prayer Times (#95156) --- .../islamic_prayer_times/config_flow.py | 51 ++++++++++++++++++- .../components/islamic_prayer_times/const.py | 12 +++++ .../islamic_prayer_times/coordinator.py | 34 ++++++++++++- .../islamic_prayer_times/strings.json | 24 ++++++++- .../islamic_prayer_times/test_config_flow.py | 18 ++++++- 5 files changed, 134 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 597d67c19f4..333b6b36c87 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -14,7 +14,22 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME +from .const import ( + CALC_METHODS, + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DEFAULT_CALC_METHOD, + DEFAULT_LAT_ADJ_METHOD, + DEFAULT_MIDNIGHT_MODE, + DEFAULT_SCHOOL, + DOMAIN, + LAT_ADJ_METHODS, + MIDNIGHT_MODES, + NAME, + SCHOOLS, +) class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -70,6 +85,40 @@ class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): translation_key=CONF_CALC_METHOD, ) ), + vol.Optional( + CONF_LAT_ADJ_METHOD, + default=self.config_entry.options.get( + CONF_LAT_ADJ_METHOD, DEFAULT_LAT_ADJ_METHOD + ), + ): SelectSelector( + SelectSelectorConfig( + options=LAT_ADJ_METHODS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_LAT_ADJ_METHOD, + ) + ), + vol.Optional( + CONF_MIDNIGHT_MODE, + default=self.config_entry.options.get( + CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE + ), + ): SelectSelector( + SelectSelectorConfig( + options=MIDNIGHT_MODES, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_MIDNIGHT_MODE, + ) + ), + vol.Optional( + CONF_SCHOOL, + default=self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL), + ): SelectSelector( + SelectSelectorConfig( + options=SCHOOLS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SCHOOL, + ) + ), } return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index 67fac6c9261..926651738a2 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -25,3 +25,15 @@ CALC_METHODS: Final = [ "custom", ] DEFAULT_CALC_METHOD: Final = "isna" + +CONF_LAT_ADJ_METHOD: Final = "latitude_adjustment_method" +LAT_ADJ_METHODS: Final = ["middle_of_the_night", "one_seventh", "angle_based"] +DEFAULT_LAT_ADJ_METHOD: Final = "middle_of_the_night" + +CONF_MIDNIGHT_MODE: Final = "midnight_mode" +MIDNIGHT_MODES: Final = ["standard", "jafari"] +DEFAULT_MIDNIGHT_MODE: Final = "standard" + +CONF_SCHOOL: Final = "school" +SCHOOLS: Final = ["shafi", "hanafi"] +DEFAULT_SCHOOL: Final = "shafi" diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 30362c763da..161ce7b2644 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -14,7 +14,17 @@ from homeassistant.helpers.event import async_call_later, async_track_point_in_t from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed import homeassistant.util.dt as dt_util -from .const import CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN +from .const import ( + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DEFAULT_CALC_METHOD, + DEFAULT_LAT_ADJ_METHOD, + DEFAULT_MIDNIGHT_MODE, + DEFAULT_SCHOOL, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -38,12 +48,34 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim """Return the calculation method.""" return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + @property + def lat_adj_method(self) -> str: + """Return the latitude adjustment method.""" + return str( + self.config_entry.options.get( + CONF_LAT_ADJ_METHOD, DEFAULT_LAT_ADJ_METHOD + ).replace("_", " ") + ) + + @property + def midnight_mode(self) -> str: + """Return the midnight mode.""" + return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) + + @property + def school(self) -> str: + """Return the school.""" + return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) + def get_new_prayer_times(self) -> dict[str, Any]: """Fetch prayer times for today.""" calc = PrayerTimesCalculator( latitude=self.hass.config.latitude, longitude=self.hass.config.longitude, calculation_method=self.calc_method, + latitudeAdjustmentMethod=self.lat_adj_method, + midnightMode=self.midnight_mode, + school=self.school, date=str(dt_util.now().date()), ) return cast(dict[str, Any], calc.fetch_prayer_times()) diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index d02b26ec533..e07a38ca107 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -15,7 +15,10 @@ "step": { "init": { "data": { - "calculation_method": "Prayer calculation method" + "calculation_method": "Prayer calculation method", + "latitude_adjustment_method": "Latitude adjustment method", + "midnight_mode": "Midnight mode", + "school": "School" } } } @@ -40,6 +43,25 @@ "moonsighting": "Moonsighting Committee Worldwide", "custom": "Custom" } + }, + "latitude_adjustment_method": { + "options": { + "middle_of_the_night": "Middle of the night", + "one_seventh": "One seventh", + "angle_based": "Angle based" + } + }, + "midnight_mode": { + "options": { + "standard": "Standard (mid sunset to sunrise)", + "jafari": "Jafari (mid sunset to fajr)" + } + }, + "school": { + "options": { + "shafi": "Shafi", + "hanafi": "Hanafi" + } } }, "entity": { diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index a25b8ba0f0b..f331c5bf49b 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -3,7 +3,13 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times -from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN +from homeassistant.components.islamic_prayer_times.const import ( + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DOMAIN, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -44,11 +50,19 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_CALC_METHOD: "makkah"} + result["flow_id"], + user_input={ + CONF_CALC_METHOD: "makkah", + CONF_LAT_ADJ_METHOD: "one_seventh", + CONF_SCHOOL: "hanafi", + }, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"][CONF_CALC_METHOD] == "makkah" + assert result["data"][CONF_LAT_ADJ_METHOD] == "one_seventh" + assert result["data"][CONF_MIDNIGHT_MODE] == "standard" + assert result["data"][CONF_SCHOOL] == "hanafi" async def test_integration_already_configured(hass: HomeAssistant) -> None: From 67de96adfa64bcf9dcd9f4cb0a3e30b4da6ecab5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 13:18:26 +0200 Subject: [PATCH 280/984] Bump actions/cache from 3.3.1 to 3.3.2 (#99903) --- .github/workflows/ci.yaml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9651b1394d8..2e1df49549e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -229,7 +229,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: venv key: >- @@ -244,7 +244,7 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -274,7 +274,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -283,7 +283,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -320,7 +320,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -329,7 +329,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -369,7 +369,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -378,7 +378,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -468,7 +468,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: venv lookup-only: true @@ -477,7 +477,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: ${{ env.PIP_CACHE }} key: >- @@ -531,7 +531,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -563,7 +563,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -596,7 +596,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -647,7 +647,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -655,7 +655,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: .mypy_cache key: >- @@ -722,7 +722,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -874,7 +874,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -998,7 +998,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true From 8742c550be71f937c886d51cbe7f0fb3e10ff711 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 06:25:25 -0500 Subject: [PATCH 281/984] Upgrade bluetooth deps to fix timeout behavior on py3.11 (#99879) --- homeassistant/components/bluetooth/manifest.json | 6 +++--- homeassistant/package_constraints.txt | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 4231e03c2ef..a3c40f739aa 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,9 +15,9 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.0", - "bleak-retry-connector==3.1.2", - "bluetooth-adapters==0.16.0", - "bluetooth-auto-recovery==1.2.1", + "bleak-retry-connector==3.1.3", + "bluetooth-adapters==0.16.1", + "bluetooth-auto-recovery==1.2.2", "bluetooth-data-tools==1.11.0", "dbus-fast==1.95.2" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 629e654bb7b..8fc7d629470 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,10 +8,10 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.1.2 +bleak-retry-connector==3.1.3 bleak==0.21.0 -bluetooth-adapters==0.16.0 -bluetooth-auto-recovery==1.2.1 +bluetooth-adapters==0.16.1 +bluetooth-auto-recovery==1.2.2 bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index a9ff02dfac5..5a9d627e6e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -519,7 +519,7 @@ bimmer-connected==0.14.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.2 +bleak-retry-connector==3.1.3 # homeassistant.components.bluetooth bleak==0.21.0 @@ -541,10 +541,10 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.0 +bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.1 +bluetooth-auto-recovery==1.2.2 # homeassistant.components.bluetooth # homeassistant.components.esphome diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74c38e5d162..ac9333a9a37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ bellows==0.36.3 bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.2 +bleak-retry-connector==3.1.3 # homeassistant.components.bluetooth bleak==0.21.0 @@ -452,10 +452,10 @@ blinkpy==0.21.0 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.0 +bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.1 +bluetooth-auto-recovery==1.2.2 # homeassistant.components.bluetooth # homeassistant.components.esphome From 98ff3e233db3bac5b4d2c815cbe6bde13b57e4c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 06:32:21 -0500 Subject: [PATCH 282/984] Fix missing name and identifiers for ELKM1 connected devices (#99828) --- homeassistant/components/elkm1/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 352c8419106..14046b7079b 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -518,6 +518,8 @@ class ElkEntity(Entity): def device_info(self) -> DeviceInfo: """Device info connecting via the ElkM1 system.""" return DeviceInfo( + name=self._element.name, + identifiers={(DOMAIN, self._unique_id)}, via_device=(DOMAIN, f"{self._prefix}_system"), ) From e69c88a0d223c604f2b663a194e8f9a896a0181e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 8 Sep 2023 07:22:08 -0500 Subject: [PATCH 283/984] Use aliases when listing pipeline languages (#99672) --- .../assist_pipeline/websocket_api.py | 10 ++++--- homeassistant/util/language.py | 11 ++++++++ .../assist_pipeline/test_websocket.py | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 57e2cc8b398..6d8fd02a217 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -332,7 +332,7 @@ async def websocket_list_languages( dialect = language_util.Dialect.parse(language_tag) languages.add(dialect.language) if pipeline_languages is not None: - pipeline_languages &= languages + pipeline_languages = language_util.intersect(pipeline_languages, languages) else: pipeline_languages = languages @@ -342,11 +342,15 @@ async def websocket_list_languages( dialect = language_util.Dialect.parse(language_tag) languages.add(dialect.language) if pipeline_languages is not None: - pipeline_languages &= languages + pipeline_languages = language_util.intersect(pipeline_languages, languages) else: pipeline_languages = languages connection.send_result( msg["id"], - {"languages": pipeline_languages}, + { + "languages": sorted(pipeline_languages) + if pipeline_languages + else pipeline_languages + }, ) diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py index 4ec8c74ffa9..73db81c91ce 100644 --- a/homeassistant/util/language.py +++ b/homeassistant/util/language.py @@ -199,3 +199,14 @@ def matches( # Score < 0 is not a match return [tag for _dialect, score, tag in scored if score[0] >= 0] + + +def intersect(languages_1: set[str], languages_2: set[str]) -> set[str]: + """Intersect two sets of languages using is_match for aliases.""" + languages = set() + for lang_1 in languages_1: + for lang_2 in languages_2: + if is_language_match(lang_1, lang_2): + languages.add(lang_1) + + return languages diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index ca631be4549..a7ba9063b3f 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -1633,3 +1633,29 @@ async def test_list_pipeline_languages( msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"languages": ["en"]} + + +async def test_list_pipeline_languages_with_aliases( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test listing pipeline languages using aliases.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.conversation.async_get_conversation_languages", + return_value={"he", "nb"}, + ), patch( + "homeassistant.components.stt.async_get_speech_to_text_languages", + return_value={"he", "no"}, + ), patch( + "homeassistant.components.tts.async_get_text_to_speech_languages", + return_value={"iw", "nb"}, + ): + await client.send_json_auto_id({"type": "assist_pipeline/language/list"}) + + # result + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"languages": ["he", "nb"]} From 5ddaf52b27e7aebc5cdc22e93cc5109935ce7999 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 14:29:59 +0200 Subject: [PATCH 284/984] Use shorthand attributes in Wilight (#99920) --- homeassistant/components/wilight/switch.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py index 101162302ae..334d750b1e1 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -149,6 +149,7 @@ class WiLightValveSwitch(WiLightDevice, SwitchEntity): """Representation of a WiLights Valve switch.""" _attr_translation_key = "watering" + _attr_icon = ICON_WATERING @property def is_on(self) -> bool: @@ -237,11 +238,6 @@ class WiLightValveSwitch(WiLightDevice, SwitchEntity): return attr - @property - def icon(self) -> str: - """Return the icon to use in the frontend.""" - return ICON_WATERING - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._client.turn_on(self._index) @@ -270,6 +266,7 @@ class WiLightValvePauseSwitch(WiLightDevice, SwitchEntity): """Representation of a WiLights Valve Pause switch.""" _attr_translation_key = "pause" + _attr_icon = ICON_PAUSE @property def is_on(self) -> bool: @@ -297,11 +294,6 @@ class WiLightValvePauseSwitch(WiLightDevice, SwitchEntity): return attr - @property - def icon(self) -> str: - """Return the icon to use in the frontend.""" - return ICON_PAUSE - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._client.turn_on(self._index) From 5f6f2c2cab4cdd1b0aabadb1f9f3d781b3b43f0a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 14:38:09 +0200 Subject: [PATCH 285/984] Use shorthand attributes in Wolflink (#99921) --- homeassistant/components/wolflink/sensor.py | 46 ++++----------------- 1 file changed, 8 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 60883a0acf5..b4d60011658 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -57,14 +57,10 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): """Initialize.""" super().__init__(coordinator) self.wolf_object = wolf_object - self.device_id = device_id + self._attr_name = wolf_object.name + self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" self._state = None - @property - def name(self): - """Return the name.""" - return f"{self.wolf_object.name}" - @property def native_value(self): """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" @@ -83,52 +79,26 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): "parent": self.wolf_object.parent, } - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return f"{self.device_id}:{self.wolf_object.parameter_id}" - class WolfLinkHours(WolfLinkSensor): """Class for hour based entities.""" - @property - def icon(self): - """Icon to display in the front Aend.""" - return "mdi:clock" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfTime.HOURS + _attr_icon = "mdi:clock" + _attr_native_unit_of_measurement = UnitOfTime.HOURS class WolfLinkTemperature(WolfLinkSensor): """Class for temperature based entities.""" - @property - def device_class(self): - """Return the device_class.""" - return SensorDeviceClass.TEMPERATURE - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfTemperature.CELSIUS + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS class WolfLinkPressure(WolfLinkSensor): """Class for pressure based entities.""" - @property - def device_class(self): - """Return the device_class.""" - return SensorDeviceClass.PRESSURE - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfPressure.BAR + _attr_device_class = SensorDeviceClass.PRESSURE + _attr_native_unit_of_measurement = UnitOfPressure.BAR class WolfLinkPercentage(WolfLinkSensor): From c6f8766b1e68eda612181677752e7060787118d0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 17:27:18 +0200 Subject: [PATCH 286/984] Use shorthand attributes in Zerproc (#99926) --- homeassistant/components/zerproc/light.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 884f87d36f6..c6be3c70e65 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -88,6 +88,12 @@ class ZerprocLight(LightEntity): def __init__(self, light) -> None: """Initialize a Zerproc light.""" self._light = light + self._attr_unique_id = light.address + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, light.address)}, + manufacturer="Zerproc", + name=light.name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -108,20 +114,6 @@ class ZerprocLight(LightEntity): "Exception disconnecting from %s", self._light.address, exc_info=True ) - @property - def unique_id(self): - """Return the ID of this light.""" - return self._light.address - - @property - def device_info(self) -> DeviceInfo: - """Device info for this light.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Zerproc", - name=self._light.name, - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" if ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs: From 38247ae86860fbcbfb8405e1ba80e6afcb8a03bb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 17:31:57 +0200 Subject: [PATCH 287/984] Use shorthand attributes in Volumio (#99918) --- .../components/volumio/media_player.py | 47 +++++-------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index d207e36e3c9..a11ea62e355 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -69,39 +69,28 @@ class Volumio(MediaPlayerEntity): | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA ) + _attr_source_list = [] def __init__(self, volumio, uid, name, info): """Initialize the media player.""" self._volumio = volumio - self._uid = uid - self._name = name - self._info = info + unique_id = uid self._state = {} - self._playlists = [] - self._currentplaylist = None self.thumbnail_cache = {} + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Volumio", + model=info["hardware"], + name=name, + sw_version=info["systemversion"], + ) async def async_update(self) -> None: """Update state.""" self._state = await self._volumio.get_state() await self._async_update_playlists() - @property - def unique_id(self): - """Return the unique id for the entity.""" - return self._uid - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Volumio", - model=self._info["hardware"], - name=self._name, - sw_version=self._info["systemversion"], - ) - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" @@ -169,16 +158,6 @@ class Volumio(MediaPlayerEntity): return RepeatMode.ALL return RepeatMode.OFF - @property - def source_list(self): - """Return the list of available input sources.""" - return self._playlists - - @property - def source(self): - """Name of the current input source.""" - return self._currentplaylist - async def async_media_next_track(self) -> None: """Send media_next command to media player.""" await self._volumio.next() @@ -235,17 +214,17 @@ class Volumio(MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Choose an available playlist and play it.""" await self._volumio.play_playlist(source) - self._currentplaylist = source + self._attr_source = source async def async_clear_playlist(self) -> None: """Clear players playlist.""" await self._volumio.clear_playlist() - self._currentplaylist = None + self._attr_source = None @Throttle(PLAYLIST_UPDATE_INTERVAL) async def _async_update_playlists(self, **kwargs): """Update available Volumio playlists.""" - self._playlists = await self._volumio.get_playlists() + self._attr_source_list = await self._volumio.get_playlists() async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any From 16f7bc7bf89c0b98ec93df9300732078011454dc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 8 Sep 2023 18:59:08 +0200 Subject: [PATCH 288/984] Update frontend to 20230908.0 (#99939) --- 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 50c557eae89..58de25fc03d 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==20230906.1"] + "requirements": ["home-assistant-frontend==20230908.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8fc7d629470..02d78b1dfef 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230906.1 +home-assistant-frontend==20230908.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5a9d627e6e1..6f36f1bca2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -995,7 +995,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230906.1 +home-assistant-frontend==20230908.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac9333a9a37..ab98ebbd035 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -778,7 +778,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230906.1 +home-assistant-frontend==20230908.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 3d403c9b6020afcce6d625a945fd5f36486c820d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 12:04:53 -0500 Subject: [PATCH 289/984] Refactor entity service calls to reduce complexity (#99783) * Refactor entity service calls to reduce complexity gets rid of the noqa C901 * Refactor entity service calls to reduce complexity gets rid of the noqa C901 * short --- homeassistant/helpers/service.py | 114 ++++++++++++++++--------------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3eb537f9649..a0fe24cb656 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -732,8 +732,59 @@ def async_set_service_schema( descriptions_cache[(domain, service)] = description +def _get_permissible_entity_candidates( + call: ServiceCall, + platforms: Iterable[EntityPlatform], + entity_perms: None | (Callable[[str, str], bool]), + target_all_entities: bool, + all_referenced: set[str] | None, +) -> list[Entity]: + """Get entity candidates that the user is allowed to access.""" + if entity_perms is not None: + # Check the permissions since entity_perms is set + if target_all_entities: + # If we target all entities, we will select all entities the user + # is allowed to control. + return [ + entity + for platform in platforms + for entity in platform.entities.values() + if entity_perms(entity.entity_id, POLICY_CONTROL) + ] + + assert all_referenced is not None + # If they reference specific entities, we will check if they are all + # allowed to be controlled. + for entity_id in all_referenced: + if not entity_perms(entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + entity_id=entity_id, + permission=POLICY_CONTROL, + ) + + elif target_all_entities: + return [ + entity for platform in platforms for entity in platform.entities.values() + ] + + # We have already validated they have permissions to control all_referenced + # entities so we do not need to check again. + assert all_referenced is not None + if single_entity := len(all_referenced) == 1 and list(all_referenced)[0]: + for platform in platforms: + if (entity := platform.entities.get(single_entity)) is not None: + return [entity] + + return [ + platform.entities[entity_id] + for platform in platforms + for entity_id in all_referenced.intersection(platform.entities) + ] + + @bind_hass -async def entity_service_call( # noqa: C901 +async def entity_service_call( hass: HomeAssistant, platforms: Iterable[EntityPlatform], func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], @@ -771,69 +822,24 @@ async def entity_service_call( # noqa: C901 else: data = call - # Check the permissions - # A list with entities to call the service on. - entity_candidates: list[Entity] = [] - - if entity_perms is None: - for platform in platforms: - platform_entities = platform.entities - if target_all_entities: - entity_candidates.extend(platform_entities.values()) - else: - assert all_referenced is not None - entity_candidates.extend( - [ - platform_entities[entity_id] - for entity_id in all_referenced.intersection(platform_entities) - ] - ) - - elif target_all_entities: - # If we target all entities, we will select all entities the user - # is allowed to control. - for platform in platforms: - entity_candidates.extend( - [ - entity - for entity in platform.entities.values() - if entity_perms(entity.entity_id, POLICY_CONTROL) - ] - ) - - else: - assert all_referenced is not None - - for platform in platforms: - platform_entities = platform.entities - platform_entity_candidates = [] - entity_id_matches = all_referenced.intersection(platform_entities) - for entity_id in entity_id_matches: - if not entity_perms(entity_id, POLICY_CONTROL): - raise Unauthorized( - context=call.context, - entity_id=entity_id, - permission=POLICY_CONTROL, - ) - - platform_entity_candidates.append(platform_entities[entity_id]) - - entity_candidates.extend(platform_entity_candidates) + entity_candidates = _get_permissible_entity_candidates( + call, + platforms, + entity_perms, + target_all_entities, + all_referenced, + ) if not target_all_entities: assert referenced is not None - # Only report on explicit referenced entities - missing = set(referenced.referenced) - + missing = referenced.referenced.copy() for entity in entity_candidates: missing.discard(entity.entity_id) - referenced.log_missing(missing) entities: list[Entity] = [] - for entity in entity_candidates: if not entity.available: continue From d624bbbc0c60d75fbd809dedb77b0156c4a1b8c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 12:07:09 -0500 Subject: [PATCH 290/984] Migrate elkm1 to use a dataclass for integration data (#99830) * Migrate elkm1 to use a dataclass for integration data * fix unsaved * slotted * missing coveragerc * Revert "missing coveragerc" This reverts commit 3397b40309033276d20fef59098b0a1b5b681a30. --- homeassistant/components/elkm1/__init__.py | 58 ++++++++++--------- .../components/elkm1/alarm_control_panel.py | 8 ++- .../components/elkm1/binary_sensor.py | 12 ++-- homeassistant/components/elkm1/climate.py | 5 +- homeassistant/components/elkm1/light.py | 7 ++- homeassistant/components/elkm1/models.py | 19 ++++++ homeassistant/components/elkm1/scene.py | 5 +- homeassistant/components/elkm1/sensor.py | 5 +- homeassistant/components/elkm1/switch.py | 5 +- 9 files changed, 77 insertions(+), 47 deletions(-) create mode 100644 homeassistant/components/elkm1/models.py diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 14046b7079b..b78157588e8 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable from enum import Enum import logging import re from types import MappingProxyType -from typing import Any, cast +from typing import Any from elkm1_lib.elements import Element from elkm1_lib.elk import Elk @@ -65,6 +66,7 @@ from .discovery import ( async_trigger_discovery, async_update_entry_from_discovery, ) +from .models import ELKM1Data SYNC_TIMEOUT = 120 @@ -303,14 +305,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: temperature_unit = UnitOfTemperature.FAHRENHEIT config["temperature_unit"] = temperature_unit - hass.data[DOMAIN][entry.entry_id] = { - "elk": elk, - "prefix": conf[CONF_PREFIX], - "mac": entry.unique_id, - "auto_configure": conf[CONF_AUTO_CONFIGURE], - "config": config, - "keypads": {}, - } + prefix: str = conf[CONF_PREFIX] + auto_configure: bool = conf[CONF_AUTO_CONFIGURE] + hass.data[DOMAIN][entry.entry_id] = ELKM1Data( + elk=elk, + prefix=prefix, + mac=entry.unique_id, + auto_configure=auto_configure, + config=config, + keypads={}, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -326,21 +330,23 @@ def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) - def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None: """Search all config entries for a given prefix.""" - for entry_id in hass.data[DOMAIN]: - if hass.data[DOMAIN][entry_id]["prefix"] == prefix: - return cast(Elk, hass.data[DOMAIN][entry_id]["elk"]) + all_elk: dict[str, ELKM1Data] = hass.data[DOMAIN] + for elk_data in all_elk.values(): + if elk_data.prefix == prefix: + return elk_data.elk return None 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) + all_elk: dict[str, ELKM1Data] = hass.data[DOMAIN] # disconnect cleanly - hass.data[DOMAIN][entry.entry_id]["elk"].disconnect() + all_elk[entry.entry_id].elk.disconnect() if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + all_elk.pop(entry.entry_id) return unload_ok @@ -421,19 +427,19 @@ def _create_elk_services(hass: HomeAssistant) -> None: def create_elk_entities( - elk_data: dict[str, Any], - elk_elements: list[Element], + elk_data: ELKM1Data, + elk_elements: Iterable[Element], element_type: str, class_: Any, entities: list[ElkEntity], ) -> list[ElkEntity] | None: """Create the ElkM1 devices of a particular class.""" - auto_configure = elk_data["auto_configure"] + auto_configure = elk_data.auto_configure - if not auto_configure and not elk_data["config"][element_type]["enabled"]: + if not auto_configure and not elk_data.config[element_type]["enabled"]: return None - elk = elk_data["elk"] + elk = elk_data.elk _LOGGER.debug("Creating elk entities for %s", elk) for element in elk_elements: @@ -441,7 +447,7 @@ def create_elk_entities( if not element.configured: continue # Only check the included list if auto configure is not - elif not elk_data["config"][element_type]["included"][element.index]: + elif not elk_data.config[element_type]["included"][element.index]: continue entities.append(class_(element, elk, elk_data)) @@ -454,13 +460,13 @@ class ElkEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize the base of all Elk devices.""" self._elk = elk self._element = element - self._mac = elk_data["mac"] - self._prefix = elk_data["prefix"] - self._temperature_unit: str = elk_data["config"]["temperature_unit"] + self._mac = elk_data.mac + self._prefix = elk_data.prefix + self._temperature_unit: str = elk_data.config["temperature_unit"] # unique_id starts with elkm1_ iff there is no prefix # it starts with elkm1m_{prefix} iff there is a prefix # this is to avoid a conflict between @@ -496,9 +502,7 @@ class ElkEntity(Entity): def initial_attrs(self) -> dict[str, Any]: """Return the underlying element's attributes as a dict.""" - attrs = {} - attrs["index"] = self._element.index + 1 - return attrs + return {"index": self._element.index + 1} def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: pass diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 3f5163a849d..bfac466caeb 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -40,6 +40,7 @@ from .const import ( DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA, ) +from .models import ELKM1Data DISPLAY_MESSAGE_SERVICE_SCHEMA = { vol.Optional("clear", default=2): vol.All(vol.Coerce(int), vol.In([0, 1, 2])), @@ -65,8 +66,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ElkM1 alarm platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] - elk = elk_data["elk"] + + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities) async_add_entities(entities) @@ -115,7 +117,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): ) _element: Area - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize Area as Alarm Control Panel.""" super().__init__(element, elk, elk_data) self._elk = elk diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index 38a72796482..95f9162468e 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ElkAttachedEntity, ElkEntity from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -22,21 +23,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" - - elk_data = hass.data[DOMAIN][config_entry.entry_id] - auto_configure = elk_data["auto_configure"] - elk = elk_data["elk"] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk + auto_configure = elk_data.auto_configure entities: list[ElkEntity] = [] for element in elk.zones: # Don't create binary sensors for zones that are analog - if element.definition in {ZoneType.TEMPERATURE, ZoneType.ANALOG_ZONE}: + if element.definition in {ZoneType.TEMPERATURE, ZoneType.ANALOG_ZONE}: # type: ignore[attr-defined] continue if auto_configure: if not element.configured: continue - elif not elk_data["config"]["zone"]["included"][element.index]: + elif not elk_data.config["zone"]["included"][element.index]: continue entities.append(ElkBinarySensor(element, elk, elk_data)) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 1ece7a7758a..c1e6dc7b034 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -23,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data SUPPORT_HVAC = [ HVACMode.OFF, @@ -61,9 +62,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 thermostat platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities( elk_data, elk.thermostats, "thermostat", ElkThermostat, entities ) diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index 3db457761aa..844e4f3dd15 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -22,9 +23,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elk light platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities) async_add_entities(entities) @@ -36,7 +37,7 @@ class ElkLight(ElkEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _element: Light - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize the Elk light.""" super().__init__(element, elk, elk_data) self._brightness = self._element.status diff --git a/homeassistant/components/elkm1/models.py b/homeassistant/components/elkm1/models.py new file mode 100644 index 00000000000..9f784951c11 --- /dev/null +++ b/homeassistant/components/elkm1/models.py @@ -0,0 +1,19 @@ +"""The elkm1 integration models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from elkm1_lib import Elk + + +@dataclass(slots=True) +class ELKM1Data: + """Data for the elkm1 integration.""" + + elk: Elk + prefix: str + mac: str | None + auto_configure: bool + config: dict[str, Any] + keypads: dict[str, Any] diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index 1869e5ba0f3..9cb0c62ff77 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -20,9 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 scene platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities) async_add_entities(entities) diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 0de97a1710e..9bd78f61673 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -23,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import ATTR_VALUE, DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA +from .models import ELKM1Data SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh" SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set" @@ -41,9 +42,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities) create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities) create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities) diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index a17557b1507..b4080adc698 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -20,9 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 switch platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities) async_add_entities(entities) From 9a45e2cf91ef6344992bcedd1cf8cb1db7ca47fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 19:08:32 +0200 Subject: [PATCH 291/984] Bump pyenphase to v1.11.0 (#99941) --- 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 d3a36b16b60..c6d127a3f6e 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.9.3"], + "requirements": ["pyenphase==1.11.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 6f36f1bca2b..8ee56b9bdc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1672,7 +1672,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.9.3 +pyenphase==1.11.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab98ebbd035..daeddff9e49 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1239,7 +1239,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.9.3 +pyenphase==1.11.0 # homeassistant.components.everlights pyeverlights==0.1.0 From bd1d8675a9f8a2f7555724d6389e90a83c4c4532 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 12:09:29 -0500 Subject: [PATCH 292/984] Avoid many hass.is_stopping calls in the discovery helper (#99929) async_has_matching_flow is more likely to be True than hass.is_stopping This does not make much difference but it was adding noise to a profile that I am digging into to look for another issue --- homeassistant/helpers/discovery_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 586824b4495..306e8b51d63 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -44,8 +44,9 @@ def _async_init_flow( # as ones in progress as it may cause additional device probing # which can overload devices since zeroconf/ssdp updates can happen # multiple times in the same minute - if hass.is_stopping or hass.config_entries.flow.async_has_matching_flow( - domain, context, data + if ( + hass.config_entries.flow.async_has_matching_flow(domain, context, data) + or hass.is_stopping ): return None From 677431ed718eaac0ffe279c63fd4e505058e2b66 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Sep 2023 19:10:17 +0200 Subject: [PATCH 293/984] Fix key error MQTT binary_sensor when no name is set (#99943) Log entitty ID when instead of name --- homeassistant/components/mqtt/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b5c7bc98789..a1341350a7a 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -215,7 +215,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): "Empty template output for entity: %s with state topic: %s." " Payload: '%s', with value template '%s'" ), - self._config[CONF_NAME], + self.entity_id, self._config[CONF_STATE_TOPIC], msg.payload, self._config.get(CONF_VALUE_TEMPLATE), @@ -240,7 +240,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): "No matching payload found for entity: %s with state topic: %s." " Payload: '%s'%s" ), - self._config[CONF_NAME], + self.entity_id, self._config[CONF_STATE_TOPIC], msg.payload, template_info, From be4ea320493eb190f7ae2a6d00dba6c2bc9f4f41 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 8 Sep 2023 19:20:06 +0200 Subject: [PATCH 294/984] Bump pymodbus v.3.5.1 (#99940) --- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index a4187de77eb..bef85f1d20d 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.5.0"] + "requirements": ["pymodbus==3.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8ee56b9bdc7..d6992e4ce17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1852,7 +1852,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.0 +pymodbus==3.5.1 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daeddff9e49..5a4488fd06a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1374,7 +1374,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.0 +pymodbus==3.5.1 # homeassistant.components.monoprice pymonoprice==0.4 From d1ac4c9c467657c4f1b548fd5088d6d01dbc48c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 12:59:25 -0500 Subject: [PATCH 295/984] Switch a few ssdp calls to use get_lower (#99931) get_lower avoids lower casing already lower-cased strings --- homeassistant/components/ssdp/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 3be5475a71a..986eabf4e82 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -606,7 +606,7 @@ def discovery_info_from_headers_and_description( ) -> SsdpServiceInfo: """Convert headers and description to discovery_info.""" ssdp_usn = combined_headers["usn"] - ssdp_st = combined_headers.get("st") + ssdp_st = combined_headers.get_lower("st") if isinstance(info_desc, CaseInsensitiveDict): upnp_info = {**info_desc.as_dict()} else: @@ -626,11 +626,11 @@ def discovery_info_from_headers_and_description( return SsdpServiceInfo( ssdp_usn=ssdp_usn, ssdp_st=ssdp_st, - ssdp_ext=combined_headers.get("ext"), - ssdp_server=combined_headers.get("server"), - ssdp_location=combined_headers.get("location"), - ssdp_udn=combined_headers.get("_udn"), - ssdp_nt=combined_headers.get("nt"), + ssdp_ext=combined_headers.get_lower("ext"), + ssdp_server=combined_headers.get_lower("server"), + ssdp_location=combined_headers.get_lower("location"), + ssdp_udn=combined_headers.get_lower("_udn"), + ssdp_nt=combined_headers.get_lower("nt"), ssdp_headers=combined_headers, upnp=upnp_info, ) From f0ee20c15c4c0f6f4e0dd9604c2dc226e3b72463 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 13:59:35 -0500 Subject: [PATCH 296/984] Bump orjson to 3.9.7 (#99938) --- 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 02d78b1dfef..59005c7bf49 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 mutagen==1.46.0 -orjson==3.9.2 +orjson==3.9.7 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.0.0 diff --git a/pyproject.toml b/pyproject.toml index e535e7bbc7b..e62bdbf3e30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==41.0.3", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.2", + "orjson==3.9.7", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index e7a3b0fc4c5..28e853f4fe1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.2.0 PyJWT==2.8.0 cryptography==41.0.3 pyOpenSSL==23.2.0 -orjson==3.9.2 +orjson==3.9.7 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 From b317e04cf154405f39a586ca8a24e703425d051b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Sep 2023 21:01:34 +0200 Subject: [PATCH 297/984] Bump hatasmota to 0.7.1 (#99818) --- homeassistant/components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_sensor.py | 10 ++++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 220bc4e31fb..9843f64fc25 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.0"] + "requirements": ["HATasmota==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index d6992e4ce17..1380ebbfa8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.0 +HATasmota==0.7.1 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a4488fd06a..c785034cc70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.0 +HATasmota==0.7.1 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 4e79b8ad0d5..c14c7ffe53c 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -626,6 +626,16 @@ async def test_battery_sensor_state_via_mqtt( "unit_of_measurement": "%", } + # Test polled state update + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS11", + '{"StatusSTS":{"BatteryPercentage":50}}', + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_battery_level") + assert state.state == "50" + @pytest.mark.parametrize("status_sensor_disabled", [False]) async def test_single_shot_status_sensor_state_via_mqtt( From 1654ef77595fc85c9d53c63609f799044472478b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Sep 2023 21:02:06 +0200 Subject: [PATCH 298/984] Make WS command render_template not give up if initial render raises (#99808) --- .../components/websocket_api/commands.py | 6 +- homeassistant/helpers/event.py | 11 +- .../components/websocket_api/test_commands.py | 317 +++++++++++++++--- tests/helpers/test_event.py | 21 -- 4 files changed, 281 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a05f2aa8e3f..ea21b7b5eba 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -542,9 +542,8 @@ async def handle_render_template( timed_out = await template_obj.async_render_will_timeout( timeout, variables, strict=msg["strict"], log_fn=log_fn ) - except TemplateError as ex: - connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) - return + except TemplateError: + timed_out = False if timed_out: connection.send_error( @@ -583,7 +582,6 @@ async def handle_render_template( hass, [TrackTemplate(template_obj, variables)], _template_listener, - raise_on_template_error=True, strict=msg["strict"], log_fn=log_fn, ) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 76e73401beb..2da8a48be98 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -917,7 +917,6 @@ class TrackTemplateResultInfo: def async_setup( self, - raise_on_template_error: bool, strict: bool = False, log_fn: Callable[[int, str], None] | None = None, ) -> None: @@ -955,8 +954,6 @@ class TrackTemplateResultInfo: ) if info.exception: - if raise_on_template_error: - raise info.exception if not log_fn: _LOGGER.error( "Error while processing template: %s", @@ -1239,7 +1236,6 @@ def async_track_template_result( hass: HomeAssistant, track_templates: Sequence[TrackTemplate], action: TrackTemplateResultListener, - raise_on_template_error: bool = False, strict: bool = False, log_fn: Callable[[int, str], None] | None = None, has_super_template: bool = False, @@ -1266,11 +1262,6 @@ def async_track_template_result( An iterable of TrackTemplate. action Callable to call with results. - raise_on_template_error - When set to True, if there is an exception - processing the template during setup, the system - will raise the exception instead of setting up - tracking. strict When set to True, raise on undefined variables. log_fn @@ -1286,7 +1277,7 @@ def async_track_template_result( """ tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) - tracker.async_setup(raise_on_template_error, strict=strict, log_fn=log_fn) + tracker.async_setup(strict=strict, log_fn=log_fn) return tracker diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 70f08477a72..b1b2027c65d 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1234,27 +1234,27 @@ EMPTY_LISTENERS = {"all": False, "entities": [], "domains": [], "time": False} ERR_MSG = {"type": "result", "success": False} -VARIABLE_ERROR_UNDEFINED_FUNC = { +EVENT_UNDEFINED_FUNC_1 = { "error": "'my_unknown_func' is undefined", "level": "ERROR", } -TEMPLATE_ERROR_UNDEFINED_FUNC = { - "code": "template_error", - "message": "UndefinedError: 'my_unknown_func' is undefined", +EVENT_UNDEFINED_FUNC_2 = { + "error": "UndefinedError: 'my_unknown_func' is undefined", + "level": "ERROR", } -VARIABLE_WARNING_UNDEFINED_VAR = { +EVENT_UNDEFINED_VAR_WARN = { "error": "'my_unknown_var' is undefined", "level": "WARNING", } -TEMPLATE_ERROR_UNDEFINED_VAR = { - "code": "template_error", - "message": "UndefinedError: 'my_unknown_var' is undefined", +EVENT_UNDEFINED_VAR_ERR = { + "error": "UndefinedError: 'my_unknown_var' is undefined", + "level": "ERROR", } -TEMPLATE_ERROR_UNDEFINED_FILTER = { - "code": "template_error", - "message": "TemplateAssertionError: No filter named 'unknown_filter'.", +EVENT_UNDEFINED_FILTER = { + "error": "TemplateAssertionError: No filter named 'unknown_filter'.", + "level": "ERROR", } @@ -1264,16 +1264,19 @@ TEMPLATE_ERROR_UNDEFINED_FILTER = { ( "{{ my_unknown_func() + 1 }}", [ - {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, - ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, ], ), ( "{{ my_unknown_var }}", [ - {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, {"type": "result", "success": True, "result": None}, - {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, { "type": "event", "event": {"result": "", "listeners": EMPTY_LISTENERS}, @@ -1282,11 +1285,19 @@ TEMPLATE_ERROR_UNDEFINED_FILTER = { ), ( "{{ my_unknown_var + 1 }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], ), ( "{{ now() | unknown_filter }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], ), ], ) @@ -1325,16 +1336,20 @@ async def test_render_template_with_error( ( "{{ my_unknown_func() + 1 }}", [ - {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, - ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, ], ), ( "{{ my_unknown_var }}", [ - {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, {"type": "result", "success": True, "result": None}, - {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, { "type": "event", "event": {"result": "", "listeners": EMPTY_LISTENERS}, @@ -1343,11 +1358,19 @@ async def test_render_template_with_error( ), ( "{{ my_unknown_var + 1 }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], ), ( "{{ now() | unknown_filter }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], ), ], ) @@ -1386,19 +1409,35 @@ async def test_render_template_with_timeout_and_error( [ ( "{{ my_unknown_func() + 1 }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}], + [ + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + ], ), ( "{{ my_unknown_var }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], ), ( "{{ my_unknown_var + 1 }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], ), ( "{{ now() | unknown_filter }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], ), ], ) @@ -1409,7 +1448,73 @@ async def test_render_template_strict_with_timeout_and_error( template: str, expected_events: list[dict[str, str]], ) -> None: - """Test a template with an error with a timeout.""" + """Test a template with an error with a timeout. + + In this test report_errors is enabled. + """ + caplog.set_level(logging.INFO) + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template, + "timeout": 5, + "strict": True, + "report_errors": True, + } + ) + + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text + assert "TemplateError" not in caplog.text + + +@pytest.mark.parametrize( + ("template", "expected_events"), + [ + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ now() | unknown_filter }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ], +) +async def test_render_template_strict_with_timeout_and_error_2( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], +) -> None: + """Test a template with an error with a timeout. + + In this test report_errors is disabled. + """ caplog.set_level(logging.INFO) await websocket_client.send_json( { @@ -1427,30 +1532,164 @@ async def test_render_template_strict_with_timeout_and_error( for key, value in expected_event.items(): assert msg[key] == value - assert "Template variable error" not in caplog.text - assert "Template variable warning" not in caplog.text - assert "TemplateError" not in caplog.text + assert "TemplateError" in caplog.text +@pytest.mark.parametrize( + ("template", "expected_events_1", "expected_events_2"), + [ + ( + "{{ now() | random }}", + [ + { + "type": "event", + "event": { + "error": "TypeError: object of type 'datetime.datetime' has no len()", + "level": "ERROR", + }, + }, + {"type": "result", "success": True, "result": None}, + { + "type": "event", + "event": { + "error": "TypeError: object of type 'datetime.datetime' has no len()", + "level": "ERROR", + }, + }, + ], + [], + ), + ( + "{{ float(states.sensor.foo.state) + 1 }}", + [ + { + "type": "event", + "event": { + "error": "UndefinedError: 'None' has no attribute 'state'", + "level": "ERROR", + }, + }, + {"type": "result", "success": True, "result": None}, + { + "type": "event", + "event": { + "error": "UndefinedError: 'None' has no attribute 'state'", + "level": "ERROR", + }, + }, + ], + [ + { + "type": "event", + "event": { + "result": 3.0, + "listeners": EMPTY_LISTENERS | {"entities": ["sensor.foo"]}, + }, + }, + ], + ), + ], +) async def test_render_template_error_in_template_code( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events_1: list[dict[str, str]], + expected_events_2: list[dict[str, str]], ) -> None: - """Test a template that will throw in template.py.""" + """Test a template that will throw in template.py. + + In this test report_errors is enabled. + """ await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "{{ now() | random }}"} + { + "id": 5, + "type": "render_template", + "template": template, + "report_errors": True, + } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events_1: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + hass.states.async_set("sensor.foo", "2") + + for expected_event in expected_events_2: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text +@pytest.mark.parametrize( + ("template", "expected_events_1", "expected_events_2"), + [ + ( + "{{ now() | random }}", + [ + {"type": "result", "success": True, "result": None}, + ], + [], + ), + ( + "{{ float(states.sensor.foo.state) + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + [ + { + "type": "event", + "event": { + "result": 3.0, + "listeners": EMPTY_LISTENERS | {"entities": ["sensor.foo"]}, + }, + }, + ], + ), + ], +) +async def test_render_template_error_in_template_code_2( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events_1: list[dict[str, str]], + expected_events_2: list[dict[str, str]], +) -> None: + """Test a template that will throw in template.py. + + In this test report_errors is disabled. + """ + await websocket_client.send_json( + {"id": 5, "type": "render_template", "template": template} + ) + + for expected_event in expected_events_1: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + hass.states.async_set("sensor.foo", "2") + + for expected_event in expected_events_2: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "TemplateError" in caplog.text + + async def test_render_template_with_delayed_error( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index dc06b9d94c8..00ad580693e 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3239,27 +3239,6 @@ async def test_async_track_template_result_multiple_templates_mixing_domain( ] -async def test_async_track_template_result_raise_on_template_error( - hass: HomeAssistant, -) -> None: - """Test that we raise as soon as we encounter a failed template.""" - - with pytest.raises(TemplateError): - async_track_template_result( - hass, - [ - TrackTemplate( - Template( - "{{ states.switch | function_that_does_not_exist | list }}" - ), - None, - ), - ], - ha.callback(lambda event, updates: None), - raise_on_template_error=True, - ) - - async def test_track_template_with_time(hass: HomeAssistant) -> None: """Test tracking template with time.""" From d59aa958b6d6eda6d8aef3dc406edce1a7301459 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Fri, 8 Sep 2023 23:03:27 +0200 Subject: [PATCH 299/984] Add tests for Minecraft Server entry migration from v1 to v2 (#99954) --- tests/components/minecraft_server/const.py | 32 +++++ .../minecraft_server/test_config_flow.py | 47 ++----- .../components/minecraft_server/test_init.py | 131 ++++++++++++++++++ 3 files changed, 176 insertions(+), 34 deletions(-) create mode 100644 tests/components/minecraft_server/const.py create mode 100644 tests/components/minecraft_server/test_init.py diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py new file mode 100644 index 00000000000..3f635fbe333 --- /dev/null +++ b/tests/components/minecraft_server/const.py @@ -0,0 +1,32 @@ +"""Constants for Minecraft Server integration tests.""" +from mcstatus.motd import Motd +from mcstatus.status_response import ( + JavaStatusPlayers, + JavaStatusResponse, + JavaStatusVersion, +) + +TEST_HOST = "mc.dummyserver.com" + +TEST_JAVA_STATUS_RESPONSE_RAW = { + "description": {"text": "Dummy Description"}, + "version": {"name": "Dummy Version", "protocol": 123}, + "players": { + "online": 3, + "max": 10, + "sample": [ + {"name": "Player 1", "id": "1"}, + {"name": "Player 2", "id": "2"}, + {"name": "Player 3", "id": "3"}, + ], + }, +} + +TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( + raw=TEST_JAVA_STATUS_RESPONSE_RAW, + players=JavaStatusPlayers.build(TEST_JAVA_STATUS_RESPONSE_RAW["players"]), + version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]), + motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False), + icon=None, + latency=5, +) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index d9e7d46a88c..c4d8c72e32d 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -1,9 +1,8 @@ -"""Test the Minecraft Server config flow.""" +"""Tests for the Minecraft Server config flow.""" from unittest.mock import AsyncMock, patch import aiodns -from mcstatus.status_response import JavaStatusResponse from homeassistant.components.minecraft_server.const import ( DEFAULT_NAME, @@ -15,39 +14,27 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE + class QueryMock: """Mock for result of aiodns.DNSResolver.query.""" def __init__(self) -> None: """Set up query result mock.""" - self.host = "mc.dummyserver.com" + self.host = TEST_HOST self.port = 23456 self.priority = 1 self.weight = 1 self.ttl = None -JAVA_STATUS_RESPONSE_RAW = { - "description": {"text": "Dummy Description"}, - "version": {"name": "Dummy Version", "protocol": 123}, - "players": { - "online": 3, - "max": 10, - "sample": [ - {"name": "Player 1", "id": "1"}, - {"name": "Player 2", "id": "2"}, - {"name": "Player 3", "id": "3"}, - ], - }, -} - USER_INPUT = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"mc.dummyserver.com:{DEFAULT_PORT}", + CONF_HOST: f"{TEST_HOST}:{DEFAULT_PORT}", } -USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: "dummyserver.com"} +USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST} USER_INPUT_IPV4 = { CONF_NAME: DEFAULT_NAME, @@ -61,12 +48,12 @@ USER_INPUT_IPV6 = { USER_INPUT_PORT_TOO_SMALL = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com:1023", + CONF_HOST: f"{TEST_HOST}:1023", } USER_INPUT_PORT_TOO_LARGE = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com:65536", + CONF_HOST: f"{TEST_HOST}:65536", } @@ -129,9 +116,7 @@ async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None side_effect=AsyncMock(return_value=[QueryMock()]), ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_SRV @@ -150,9 +135,7 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -161,7 +144,7 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_HOST] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] - assert result["data"][CONF_HOST] == "mc.dummyserver.com" + assert result["data"][CONF_HOST] == TEST_HOST async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: @@ -171,9 +154,7 @@ async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 @@ -192,9 +173,7 @@ async def test_connection_succeeded_with_ip6(hass: HomeAssistant) -> None: side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6 diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py new file mode 100644 index 00000000000..5bdce5ed9b7 --- /dev/null +++ b/tests/components/minecraft_server/test_init.py @@ -0,0 +1,131 @@ +"""Tests for the Minecraft Server integration.""" +from unittest.mock import patch + +import aiodns + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.minecraft_server.const import ( + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE + +from tests.common import MockConfigEntry + +TEST_UNIQUE_ID = f"{TEST_HOST}-{DEFAULT_PORT}" + +SENSOR_KEYS = [ + {"v1": "Latency Time", "v2": "latency"}, + {"v1": "Players Max", "v2": "players_max"}, + {"v1": "Players Online", "v2": "players_online"}, + {"v1": "Protocol Version", "v2": "protocol_version"}, + {"v1": "Version", "v2": "version"}, + {"v1": "World Message", "v2": "motd"}, +] + +BINARY_SENSOR_KEYS = {"v1": "Status", "v2": "status"} + + +async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: + """Test entry migratiion from version 1 to 2.""" + + # Create mock config entry. + config_entry_v1 = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_UNIQUE_ID, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_HOST: TEST_HOST, + CONF_PORT: DEFAULT_PORT, + }, + version=1, + ) + config_entry_id = config_entry_v1.entry_id + config_entry_v1.add_to_hass(hass) + + # Create mock device entry. + device_registry = dr.async_get(hass) + device_entry_v1 = device_registry.async_get_or_create( + config_entry_id=config_entry_id, + identifiers={(DOMAIN, TEST_UNIQUE_ID)}, + ) + device_entry_id = device_entry_v1.id + assert device_entry_v1 + assert device_entry_id + + # Create mock sensor entity entries. + sensor_entity_id_key_mapping_list = [] + entity_registry = er.async_get(hass) + for sensor_key in SENSOR_KEYS: + entity_unique_id = f"{TEST_UNIQUE_ID}-{sensor_key['v1']}" + entity_entry_v1 = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id=entity_unique_id, + config_entry=config_entry_v1, + device_id=device_entry_id, + ) + assert entity_entry_v1.unique_id == entity_unique_id + sensor_entity_id_key_mapping_list.append( + {"entity_id": entity_entry_v1.entity_id, "key": sensor_key["v2"]} + ) + + # Create mock binary sensor entity entry. + entity_unique_id = f"{TEST_UNIQUE_ID}-{BINARY_SENSOR_KEYS['v1']}" + entity_entry_v1 = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id=entity_unique_id, + config_entry=config_entry_v1, + device_id=device_entry_id, + ) + assert entity_entry_v1.unique_id == entity_unique_id + binary_sensor_entity_id_key_mapping = { + "entity_id": entity_entry_v1.entity_id, + "key": BINARY_SENSOR_KEYS["v2"], + } + + # Trigger migration. + with patch( + "aiodns.DNSResolver.query", + side_effect=aiodns.error.DNSError, + ), patch( + "mcstatus.server.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ): + assert await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + + # Test migrated config entry. + config_entry_v2 = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry_v2.unique_id is None + assert config_entry_v2.data == { + CONF_NAME: DEFAULT_NAME, + CONF_HOST: TEST_HOST, + CONF_PORT: DEFAULT_PORT, + } + assert config_entry_v2.version == 2 + + # Test migrated device entry. + device_entry_v2 = device_registry.async_get(device_entry_id) + assert device_entry_v2.identifiers == {(DOMAIN, config_entry_id)} + + # Test migrated sensor entity entries. + for mapping in sensor_entity_id_key_mapping_list: + entity_entry_v2 = entity_registry.async_get(mapping["entity_id"]) + assert entity_entry_v2.unique_id == f"{config_entry_id}-{mapping['key']}" + + # Test migrated binary sensor entity entry. + entity_entry_v2 = entity_registry.async_get( + binary_sensor_entity_id_key_mapping["entity_id"] + ) + assert ( + entity_entry_v2.unique_id + == f"{config_entry_id}-{binary_sensor_entity_id_key_mapping['key']}" + ) From 75f923a86e1e3aa6dc6d9c3cfe6a8184de2b7137 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 01:16:51 +0200 Subject: [PATCH 300/984] Use device class translations for Devolo Update entity (#99235) --- homeassistant/components/devolo_home_network/update.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 21f6edd862c..1c95c4262b2 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -92,7 +92,6 @@ class DevoloUpdateEntity(DevoloCoordinatorEntity, UpdateEntity): """Initialize entity.""" self.entity_description = description super().__init__(entry, coordinator, device) - self._attr_translation_key = None self._in_progress_old_version: str | None = None @property @@ -124,7 +123,7 @@ class DevoloUpdateEntity(DevoloCoordinatorEntity, UpdateEntity): except DevicePasswordProtected as ex: self.entry.async_start_reauth(self.hass) raise HomeAssistantError( - f"Device {self.entry.title} require re-authenticatication to set or change the password" + f"Device {self.entry.title} require re-authentication to set or change the password" ) from ex except DeviceUnavailable as ex: raise HomeAssistantError( From e163e00acd6f396472c094b5e7ef1cfa85b46c3e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Sep 2023 01:51:26 +0200 Subject: [PATCH 301/984] Update RestrictedPython to 6.2 (#99955) --- homeassistant/components/python_script/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index ea153be11cf..80ed6164e74 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==6.1"] + "requirements": ["RestrictedPython==6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1380ebbfa8a..76c13000777 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -122,7 +122,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.1 +RestrictedPython==6.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c785034cc70..d0de16d9475 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.1 +RestrictedPython==6.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 From 694638cbc05c9d793288092f4d565637c69d0c56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 19:39:30 -0500 Subject: [PATCH 302/984] Bump bleak to 0.21.1 (#99960) --- 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 a3c40f739aa..393326d2687 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -14,7 +14,7 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.21.0", + "bleak==0.21.1", "bleak-retry-connector==3.1.3", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.2", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 59005c7bf49..f67d82da22e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -9,7 +9,7 @@ attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.1.3 -bleak==0.21.0 +bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.2 bluetooth-data-tools==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index 76c13000777..6942bba1ead 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.1.3 # homeassistant.components.bluetooth -bleak==0.21.0 +bleak==0.21.1 # homeassistant.components.blebox blebox-uniapi==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0de16d9475..41eaa6f7fc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,7 +440,7 @@ bimmer-connected==0.14.0 bleak-retry-connector==3.1.3 # homeassistant.components.bluetooth -bleak==0.21.0 +bleak==0.21.1 # homeassistant.components.blebox blebox-uniapi==2.1.4 From f903cd6fc07d68ab589374e9239656b9c966dd77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 21:16:21 -0500 Subject: [PATCH 303/984] Bump dbus-fast to 2.0.1 (#99894) --- 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 393326d2687..d6753adf3c4 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.2", "bluetooth-data-tools==1.11.0", - "dbus-fast==1.95.2" + "dbus-fast==2.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f67d82da22e..3c6be4df133 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.95.2 +dbus-fast==2.0.1 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6942bba1ead..6462cbda3b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.2 +dbus-fast==2.0.1 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41eaa6f7fc8..9b021aad92d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.2 +dbus-fast==2.0.1 # homeassistant.components.debugpy debugpy==1.6.7 From cf47a6c515caf37a3ef0562509d6f86572ca5469 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 9 Sep 2023 11:12:44 +0200 Subject: [PATCH 304/984] Add UniFi device uptime and temperature sensors (#99307) * Add UniFi device uptime and temperature sensors * Add native_unit_of_measurement to temperature Remove seconds and milliseconds from device uptime --- homeassistant/components/unifi/sensor.py | 50 ++++++++++- tests/components/unifi/test_sensor.py | 107 ++++++++++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 142bd587853..7cb0b2bbfe3 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -27,6 +27,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + UnitOfTemperature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfPower @@ -88,6 +89,16 @@ def async_wlan_client_value_fn(controller: UniFiController, wlan: Wlan) -> int: ) +@callback +def async_device_uptime_value_fn( + controller: UniFiController, device: Device +) -> datetime: + """Calculate the uptime of the device.""" + return (dt_util.now() - timedelta(seconds=device.uptime)).replace( + second=0, microsecond=0 + ) + + @callback def async_device_outlet_power_supported_fn( controller: UniFiController, obj_id: str @@ -178,7 +189,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( value_fn=lambda _, obj: obj.poe_power if obj.poe_mode != "off" else "0", ), UnifiSensorEntityDescription[Clients, Client]( - key="Uptime sensor", + key="Client uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, @@ -272,6 +283,43 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( unique_id_fn=lambda controller, obj_id: f"ac_power_conumption-{obj_id}", value_fn=lambda controller, device: device.outlet_ac_power_consumption, ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "Uptime", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"device_uptime-{obj_id}", + value_fn=async_device_uptime_value_fn, + ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device temperature", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "Temperature", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda ctrlr, obj_id: ctrlr.api.devices[obj_id].has_temperature, + unique_id_fn=lambda controller, obj_id: f"device_temperature-{obj_id}", + value_fn=lambda ctrlr, device: device.general_temperature, + ), ) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 7ed87512f2b..7b6a3bc1edc 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -566,7 +566,7 @@ async def test_poe_port_switches( ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get("sensor.mock_name_port_1_poe_power") @@ -788,8 +788,8 @@ async def test_outlet_power_readings( """Test the outlet power reporting on PDU devices.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) - assert len(hass.states.async_all()) == 9 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + assert len(hass.states.async_all()) == 10 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}") @@ -809,3 +809,104 @@ async def test_outlet_power_readings( sensor_data = hass.states.get(f"sensor.{entity_id}") assert sensor_data.state == expected_update_value + + +async def test_device_uptime( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that uptime sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_uptime").entity_category + is EntityCategory.DIAGNOSTIC + ) + + # Verify normal new event doesn't change uptime + # 4 seconds has passed + + device["uptime"] = 64 + now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + + assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" + + # Verify new event change uptime + # 1 month has passed + + device["uptime"] = 60 + now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + + assert hass.states.get("sensor.device_uptime").state == "2021-02-01T01:00:00+00:00" + + +async def test_device_temperature( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that temperature sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "general_temperature": 30, + "has_fan": True, + "has_temperature": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + assert hass.states.get("sensor.device_temperature").state == "30" + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_temperature").entity_category + is EntityCategory.DIAGNOSTIC + ) + + # Verify new event change temperature + device["general_temperature"] = 60 + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + assert hass.states.get("sensor.device_temperature").state == "60" From dced72f2ddbba3022471c4b5fc96f6c194a76bc7 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 9 Sep 2023 08:15:28 -0400 Subject: [PATCH 305/984] Bump python-roborock to 33.2 (#99962) bump to 33.2 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 01548a6334c..dfcac67d2b0 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.32.3"] + "requirements": ["python-roborock==0.33.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6462cbda3b8..5341de900da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2160,7 +2160,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.32.3 +python-roborock==0.33.2 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b021aad92d..da11cbb5775 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1589,7 +1589,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.32.3 +python-roborock==0.33.2 # homeassistant.components.smarttub python-smarttub==0.0.33 From 483e9c92bd47271f1fb4fd070d27129678f2971d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Sep 2023 14:53:25 +0200 Subject: [PATCH 306/984] Update black to 23.9.0 (#99965) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 77740d6279e..50829592f53 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: args: - --fix - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.7.0 + rev: 23.9.0 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 844d796e7af..9663d0a8fb7 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.7.0 +black==23.9.0 codespell==2.2.2 ruff==0.0.285 yamllint==1.32.0 From c77eb708861fbe0feea07a9a0fc7c246eb7afb33 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Sep 2023 15:36:47 +0200 Subject: [PATCH 307/984] Add black caching [ci] (#99967) Co-authored-by: J. Nick Koston --- .github/workflows/ci.yaml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2e1df49549e..c20886f2342 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 + BLACK_CACHE_VERSION: 1 HA_SHORT_VERSION: "2023.10" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" @@ -55,6 +56,7 @@ env: POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']" PRE_COMMIT_CACHE: ~/.cache/pre-commit PIP_CACHE: /tmp/pip-cache + BLACK_CACHE: /tmp/black-cache SQLALCHEMY_WARN_20: 1 PYTHONASYNCIODEBUG: 1 HASS_CI: 1 @@ -272,6 +274,13 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true + - name: Generate partial black restore key + id: generate-black-key + run: | + black_version=$(cat requirements_test_pre_commit.txt | grep black | cut -d '=' -f 3) + echo "version=$black_version" >> $GITHUB_OUTPUT + echo "key=black-${{ env.BLACK_CACHE_VERSION }}-$black_version-${{ + env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv uses: actions/cache/restore@v3.3.2 @@ -290,14 +299,28 @@ jobs: key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} + - name: Restore black cache + uses: actions/cache@v3.3.2 + with: + path: ${{ env.BLACK_CACHE }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + steps.generate-black-key.outputs.key }} + restore-keys: | + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-black-${{ + env.BLACK_CACHE_VERSION }}-${{ steps.generate-black-key.outputs.version }}-${{ + env.HA_SHORT_VERSION }}- - name: Run black (fully) - if: needs.info.outputs.test_full_suite == 'true' + env: + BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | . venv/bin/activate pre-commit run --hook-stage manual black --all-files --show-diff-on-failure - name: Run black (partially) if: needs.info.outputs.test_full_suite == 'false' shell: bash + env: + BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | . venv/bin/activate shopt -s globstar From 74a7bccd659b91e2a1fda1947422b19a35015fae Mon Sep 17 00:00:00 2001 From: Thomas Roager <33527165+Roagert@users.noreply.github.com> Date: Sat, 9 Sep 2023 16:01:32 +0200 Subject: [PATCH 308/984] Add zdb5100 light to zwave_js (#97586) * added zdb5100 light * added light to zdb5100 * Update tests/components/zwave_js/conftest.py agree Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/conftest.py agree Co-authored-by: Martin Hjelmare * Rename logic_group_zdb5100_light_state.json to logic_group_zdb5100_state.json name change * Update tests/components/zwave_js/test_light.py Co-authored-by: Martin Hjelmare * Update test_light.py updated test and state * Update test_light.py incorrect endpoint * changed the state --------- Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 13 + tests/components/zwave_js/conftest.py | 14 + .../fixtures/logic_group_zdb5100_state.json | 4691 +++++++++++++++++ tests/components/zwave_js/test_light.py | 178 + 4 files changed, 4896 insertions(+) create mode 100644 tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c879cc1f5b4..d54dc659be1 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -588,6 +588,19 @@ DISCOVERY_SCHEMAS = [ ), absent_values=[SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA], ), + # Logic Group ZDB5100 + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + hint="black_is_off", + manufacturer_id={0x0234}, + product_id={0x0121}, + product_type={0x0003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_COLOR}, + property={CURRENT_COLOR_PROPERTY}, + property_key={None}, + ), + ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index dcd847a6e12..e950ff0402c 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -650,6 +650,12 @@ def nice_ibt4zwave_state_fixture(): return json.loads(load_fixture("zwave_js/cover_nice_ibt4zwave_state.json")) +@pytest.fixture(name="logic_group_zdb5100_state", scope="session") +def logic_group_zdb5100_state_fixture(): + """Load the Logic Group ZDB5100 node state fixture data.""" + return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) + + # model fixtures @@ -1262,3 +1268,11 @@ def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state): node = Node(client, copy.deepcopy(nice_ibt4zwave_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="logic_group_zdb5100") +def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): + """Mock a ZDB5100 light node.""" + node = Node(client, copy.deepcopy(logic_group_zdb5100_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json b/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json new file mode 100644 index 00000000000..b570e9cea34 --- /dev/null +++ b/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json @@ -0,0 +1,4691 @@ +{ + "nodeId": 116, + "index": 0, + "installerIcon": 5632, + "userIcon": 5632, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 564, + "productId": 289, + "productType": 3, + "firmwareVersion": "1.8.0", + "zwavePlusVersion": 1, + "name": "matrix_office", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/config/zdb5100.json", + "isEmbedded": false, + "manufacturer": "Logic Group", + "manufacturerId": 564, + "label": "ZDB5100", + "description": "Wall Controller", + "devices": [ + { + "productType": 3, + "productId": 289 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "endpoints": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "disableBasicMapping": true + }, + "metadata": { + "inclusion": "Remove white button cover and press on the center switch with a non-conductive object. The LEDs will now start blinking on button 1 (upper left button)", + "exclusion": "Remove white button cover and press on the center switch with a non-conductive object. The LEDs will now start blinking on button 1 (upper left button)", + "reset": "Remove white button cover and long-press the center switch for 10 seconds with a non-conductive object. Please use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/3399/MATRIX_ZDB5100_User_Manual_1_01-EN.pdf" + } + }, + "label": "ZDB5100", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 5, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 116, + "index": 0, + "installerIcon": 5632, + "userIcon": 5632, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": false + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 1, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "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": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 2, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "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": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 3, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "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": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 4, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "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": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 5, + "installerIcon": 1536, + "userIcon": 1537, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 32, + "name": "Basic", + "version": 2, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "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": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "003", + "propertyName": "scene", + "propertyKeyName": "003", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 003", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "004", + "propertyName": "scene", + "propertyKeyName": "004", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 004", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 1, + "propertyName": "Button 1", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 2, + "propertyName": "Button 2", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 4, + "propertyName": "Button 3", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 8, + "propertyName": "Button 4", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Duration of Dimming", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Duration of Dimming", + "default": 5, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Duration of Dimming" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Duration of On/Off", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Duration of On/Off", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Duration of On/Off" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Dimmer Mode", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer Mode", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Switch only", + "1": "Trailing edge", + "2": "Leading edge" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Dimmer Mode" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Dimmer: Minimum Level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer: Minimum Level", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Dimmer: Minimum Level" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Dimmer: Maximum Level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer: Maximum Level", + "default": 99, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Dimmer: Maximum Level" + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Central Scene", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Central Scene", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Central Scene" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Double Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Double Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Double Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Enhanced LED Control", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enhanced LED Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Enhanced LED Control" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Button Debounce Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Debounce Timer", + "default": 5, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Debounce Timer" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Button Press Threshold Time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Press Threshold Time", + "default": 20, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Press Threshold Time" + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Button Held Threshold Time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Held Threshold Time", + "default": 50, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Held Threshold Time" + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 4278190080, + "propertyName": "LED Indicator Brightness: Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Red", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 16711680, + "propertyName": "LED Indicator Brightness: Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Green", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Green" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 65280, + "propertyName": "LED Indicator Brightness: Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Blue", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Blue" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 1, + "propertyName": "Send Association Group 2 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 2 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 2 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 2, + "propertyName": "Send Association Group 3 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 3 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 3 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 4, + "propertyName": "Send Association Group 4 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 4 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 4 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 8, + "propertyName": "Send Association Group 5 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 5 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 5 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 16, + "propertyName": "Send Association Group 6 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 6 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 6 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 32, + "propertyName": "Send Association Group 7 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 7 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 7 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 64, + "propertyName": "Send Association Group 8 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 8 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 8 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 128, + "propertyName": "Send Association Group 9 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 9 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 9 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 256, + "propertyName": "Send Association Group 10 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 10 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 10 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 512, + "propertyName": "Send Association Group 11 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 11 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 11 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 1024, + "propertyName": "Send Association Group 12 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 12 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 12 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 2048, + "propertyName": "Send Association Group 13 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 13 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 13 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 4096, + "propertyName": "Send Association Group 14 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 14 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 14 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Button 1 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Button 1 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 4278190080, + "propertyName": "Button 1 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 16711680, + "propertyName": "Button 1 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 65280, + "propertyName": "Button 1 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Button 1 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Button 1 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Button 1 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 4278190080, + "propertyName": "Button 1 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 16711680, + "propertyName": "Button 1 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 65280, + "propertyName": "Button 1 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 255, + "propertyName": "LED Time For Button 1 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 1 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 1 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4278190080, + "propertyName": "Button 1 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16711680, + "propertyName": "Button 1 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 65280, + "propertyName": "Button 1 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 255, + "propertyName": "LED Time For Button 1 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 1 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 1 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Button 2 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Button 2 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 4278190080, + "propertyName": "Button 2 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 16711680, + "propertyName": "Button 2 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 65280, + "propertyName": "Button 2 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 27, + "propertyName": "Button 2 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 28, + "propertyName": "Button 2 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 29, + "propertyName": "Button 2 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Button 2 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Button 2 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Button 2 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "LED Time For Button 2 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 2 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 2 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Button 2 LED Indicator (Off) Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Button 2 LED Indicator (Off) Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Button 2 LED Indicator (Off) Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "LED Time For Button 2 (Off) Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 2 (Off) Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 2 (Off) Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyName": "Button 3 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyName": "Button 3 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Button 3 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Button 3 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Button 3 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "Button 3 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Button 3 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyName": "Button 3 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 4278190080, + "propertyName": "Button 3 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 16711680, + "propertyName": "Button 3 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 65280, + "propertyName": "Button 3 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 255, + "propertyName": "LED Time For Button 3 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 3 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 3 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 4278190080, + "propertyName": "Button 3 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 16711680, + "propertyName": "Button 3 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 65280, + "propertyName": "Button 3 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 255, + "propertyName": "LED Time For Button 3 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 3 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 3 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Button 4 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyName": "Button 4 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 4278190080, + "propertyName": "Button 4 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 16711680, + "propertyName": "Button 4 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 65280, + "propertyName": "Button 4 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 43, + "propertyName": "Button 4 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 44, + "propertyName": "Button 4 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 45, + "propertyName": "Button 4 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 4278190080, + "propertyName": "Button 4 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 16711680, + "propertyName": "Button 4 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 65280, + "propertyName": "Button 4 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 255, + "propertyName": "LED Time For Button 4 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 4 (On): Blinking", + "default": 1, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 4 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 4278190080, + "propertyName": "Button 4 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 16711680, + "propertyName": "Button 4 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 65280, + "propertyName": "Button 4 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 255, + "propertyName": "LED Time For Button 4 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 4 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 4 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Dimmer on level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "", + "label": "Dimmer on level", + "default": 0, + "min": 0, + "max": 227, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": false, + "name": "Dimmer on level", + "info": "" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 564 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 289 + }, + { + "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": "5.3" + }, + { + "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.8"] + }, + { + "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": 2 + }, + { + "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 + }, + "value": "6.71.3" + }, + { + "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 + }, + "value": "3.1.1" + }, + { + "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 + }, + "value": 52445 + }, + { + "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 + }, + "value": "unused" + }, + { + "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 + }, + "value": 0 + }, + { + "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 + }, + "value": "5.3.0" + }, + { + "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 + }, + "value": 43 + }, + { + "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 + }, + "value": "1.8.0" + }, + { + "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 + }, + "value": 1 + }, + { + "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": false + }, + { + "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 + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "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": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "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 + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 116, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "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 + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 116, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "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": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "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": 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": 5, + "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": 5, + "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": 5, + "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"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "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"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0234:0x0003:0x0121:1.8.0", + "statistics": { + "commandsTX": 416, + "commandsRX": 415, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 29.4, + "lastSeen": "2023-08-20T09:41:00.683Z", + "rssi": -71, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -71, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 3a862ee3a0c..4b0345b00ea 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -35,6 +35,7 @@ from .common import ( ) HSM200_V1_ENTITY = "light.hsm200" +ZDB5100_ENTITY = "light.matrix_office" async def test_light( @@ -681,3 +682,180 @@ async def test_black_is_off( "property": "targetColor", } assert args["value"] == {"red": 255, "green": 76, "blue": 255} + + +async def test_black_is_off_zdb5100( + hass: HomeAssistant, client, logic_group_zdb5100, integration +) -> None: + """Test the black is off light entity.""" + node = logic_group_zdb5100 + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_OFF + + # Attempt to turn on the light and ensure it defaults to white + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 255, "blue": 255} + + client.async_send_command.reset_mock() + + # Force the light to turn off + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_OFF + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 0, "blue": 0} + + client.async_send_command.reset_mock() + + # Assert that the last color is restored + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 255, "blue": 0} + + client.async_send_command.reset_mock() + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": None, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + # Assert that call fails if attribute is added to service call + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_RGBW_COLOR: (255, 76, 255, 0)}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 76, "blue": 255} From e4256624948c39f8d2ecb909bec335a269f0246d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Sep 2023 16:18:47 +0200 Subject: [PATCH 309/984] Bump pytrafikverket to 0.3.6 (#99869) * Bump pytrafikverket to 0.3.6 * Fix config flow names * str --- .../trafikverket_camera/config_flow.py | 29 ++++++++++++------- .../trafikverket_camera/manifest.json | 2 +- .../trafikverket_ferry/manifest.json | 2 +- .../trafikverket_train/manifest.json | 2 +- .../trafikverket_weatherstation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../trafikverket_camera/test_config_flow.py | 6 ++-- 8 files changed, 28 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index b8a14a5424e..e1f8220c4ff 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -10,7 +10,7 @@ from pytrafikverket.exceptions import ( NoCameraFound, UnknownError, ) -from pytrafikverket.trafikverket_camera import TrafikverketCamera +from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries @@ -29,14 +29,17 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry | None - async def validate_input(self, sensor_api: str, location: str) -> dict[str, str]: + async def validate_input( + self, sensor_api: str, location: str + ) -> tuple[dict[str, str], str | None]: """Validate input from user input.""" errors: dict[str, str] = {} + camera_info: CameraInfo | None = None web_session = async_get_clientsession(self.hass) camera_api = TrafikverketCamera(web_session, sensor_api) try: - await camera_api.async_get_camera(location) + camera_info = await camera_api.async_get_camera(location) except NoCameraFound: errors["location"] = "invalid_location" except MultipleCamerasFound: @@ -46,7 +49,8 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except UnknownError: errors["base"] = "cannot_connect" - return errors + camera_location = camera_info.location if camera_info else None + return (errors, camera_location) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" @@ -58,13 +62,15 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm re-authentication with Trafikverket.""" - errors = {} + errors: dict[str, str] = {} if user_input: 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_LOCATION] + ) if not errors: self.hass.config_entries.async_update_entry( @@ -91,22 +97,23 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input: api_key = user_input[CONF_API_KEY] location = user_input[CONF_LOCATION] - errors = await self.validate_input(api_key, location) + errors, camera_location = await self.validate_input(api_key, location) if not errors: - await self.async_set_unique_id(f"{DOMAIN}-{location}") + assert camera_location + await self.async_set_unique_id(f"{DOMAIN}-{camera_location}") self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_LOCATION], + title=camera_location, data={ CONF_API_KEY: api_key, - CONF_LOCATION: location, + CONF_LOCATION: camera_location, }, ) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 440d7237171..d23631c6878 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 47f1e62be00..9d0b904290c 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 47b4c21c867..ab1f7feb3f7 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 8c46afa5972..138af544066 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5341de900da..f6929065d3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.5 +pytrafikverket==0.3.6 # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da11cbb5775..c22770d9d2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1619,7 +1619,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.5 +pytrafikverket==0.3.6 # homeassistant.components.usb pyudev==0.23.2 diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index 38c49d54208..aa6122b7efe 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -10,6 +10,7 @@ from pytrafikverket.exceptions import ( NoCameraFound, UnknownError, ) +from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN @@ -20,7 +21,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -31,6 +32,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + return_value=get_camera, ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -39,7 +41,7 @@ async def test_form(hass: HomeAssistant) -> None: result["flow_id"], { CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_LOCATION: "Test loc", }, ) await hass.async_block_till_done() From bb2cdbe7bca61171cb3163eaea18cbbc2e99a262 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 09:54:40 -0500 Subject: [PATCH 310/984] Change SSDP discovery scan interval to 10 minutes (#99975) * Change SSDP discovery scan interval to 10 minutes The first version used a scan interval of 1 minute which we increased to 2 minutes because it generated too much traffic. We kept it at 2 minutes because Sonos historicly needed to get SSDP discovery to stay alive. This is no longer the case as Sonos has multiple ways to keep from going unavailable: - mDNS support was added - We now listen for SSDP alive and good bye all the time - Each incoming packet from the device keeps it alive now - We probe when we think the device might be offline This means it should no longer be necessary to have such a frequent scan which is a drag on all devices on the network since its multicast * adjust tests --- homeassistant/components/ssdp/__init__.py | 2 +- tests/components/ssdp/test_init.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 986eabf4e82..aaffc5a157a 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -63,7 +63,7 @@ SSDP_SCANNER = "scanner" UPNP_SERVER = "server" UPNP_SERVER_MIN_PORT = 40000 UPNP_SERVER_MAX_PORT = 40100 -SCAN_INTERVAL = timedelta(minutes=2) +SCAN_INTERVAL = timedelta(minutes=10) IPV4_BROADCAST = IPv4Address("255.255.255.255") diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index ed5241a42ad..324136c011b 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,5 +1,5 @@ """Test the SSDP integration.""" -from datetime import datetime, timedelta +from datetime import datetime from ipaddress import IPv4Address from unittest.mock import ANY, AsyncMock, patch @@ -447,7 +447,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -455,7 +455,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -785,7 +785,7 @@ async def test_ipv4_does_additional_search_for_sonos( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_search.call_count == 6 From fdddbd73633c6e58f9b6bbd63fc77aa8c3cbc968 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 9 Sep 2023 17:45:19 +0200 Subject: [PATCH 311/984] Bump pymodbus to v3.5.2 (#99988) --- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index bef85f1d20d..b70055e5fbe 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.5.1"] + "requirements": ["pymodbus==3.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f6929065d3c..89dbf774fe7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1852,7 +1852,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.1 +pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c22770d9d2f..94a48d0793e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1374,7 +1374,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.1 +pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 From 9be16d9d42a05409c8fd4db6fcc5456fb9ee5312 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 17:49:54 +0200 Subject: [PATCH 312/984] Add config flow to WAQI (#98220) * Migrate WAQI to aiowaqi library * Migrate WAQI to aiowaqi library * Migrate WAQI to aiowaqi library * Add config flow to WAQI * Finish config flow * Add tests * Add tests * Fix ruff * Add issues on failing to import * Add issues on failing to import * Add issues on failing to import * Add importing issue * Finish coverage * Remove url from translation string * Fix feedback * Fix feedback --- CODEOWNERS | 3 +- homeassistant/components/waqi/__init__.py | 38 +++- homeassistant/components/waqi/config_flow.py | 135 ++++++++++++++ homeassistant/components/waqi/const.py | 10 + homeassistant/components/waqi/coordinator.py | 36 ++++ homeassistant/components/waqi/manifest.json | 3 +- homeassistant/components/waqi/sensor.py | 173 ++++++++++-------- homeassistant/components/waqi/strings.json | 39 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/waqi/__init__.py | 1 + tests/components/waqi/conftest.py | 30 +++ .../waqi/fixtures/air_quality_sensor.json | 160 ++++++++++++++++ .../waqi/fixtures/search_result.json | 32 ++++ tests/components/waqi/test_config_flow.py | 108 +++++++++++ tests/components/waqi/test_sensor.py | 124 +++++++++++++ 17 files changed, 822 insertions(+), 76 deletions(-) create mode 100644 homeassistant/components/waqi/config_flow.py create mode 100644 homeassistant/components/waqi/const.py create mode 100644 homeassistant/components/waqi/coordinator.py create mode 100644 homeassistant/components/waqi/strings.json create mode 100644 tests/components/waqi/__init__.py create mode 100644 tests/components/waqi/conftest.py create mode 100644 tests/components/waqi/fixtures/air_quality_sensor.json create mode 100644 tests/components/waqi/fixtures/search_result.json create mode 100644 tests/components/waqi/test_config_flow.py create mode 100644 tests/components/waqi/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 0cb1bef6191..ba792b07183 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1391,7 +1391,8 @@ build.json @home-assistant/supervisor /tests/components/wake_word/ @home-assistant/core @synesthesiam /homeassistant/components/wallbox/ @hesselonline /tests/components/wallbox/ @hesselonline -/homeassistant/components/waqi/ @andrey-git +/homeassistant/components/waqi/ @joostlek +/tests/components/waqi/ @joostlek /homeassistant/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core /homeassistant/components/watson_tts/ @rutkai diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 5cacd9e5e1b..bc51a91364c 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -1 +1,37 @@ -"""The waqi component.""" +"""The World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +from aiowaqi import WAQIClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import WAQIDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up World Air Quality Index (WAQI) from a config entry.""" + + client = WAQIClient(session=async_get_clientsession(hass)) + client.authenticate(entry.data[CONF_API_KEY]) + + waqi_coordinator = WAQIDataUpdateCoordinator(hass, client) + await waqi_coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator + + 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/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py new file mode 100644 index 00000000000..b5f3a18b223 --- /dev/null +++ b/homeassistant/components/waqi/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiowaqi import ( + WAQIAirQuality, + WAQIAuthenticationError, + WAQIClient, + WAQIConnectionError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.selector import LocationSelector +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER + +_LOGGER = logging.getLogger(__name__) + + +class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for World Air Quality Index (WAQI).""" + + 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: + async with WAQIClient( + session=async_get_clientsession(self.hass) + ) as waqi_client: + waqi_client.authenticate(user_input[CONF_API_KEY]) + location = user_input[CONF_LOCATION] + try: + measuring_station: WAQIAirQuality = ( + await waqi_client.get_by_coordinates( + location[CONF_LATITUDE], location[CONF_LONGITUDE] + ) + ) + except WAQIAuthenticationError: + errors["base"] = "invalid_auth" + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(measuring_station.station_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=measuring_station.city.name, + data={ + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_STATION_NUMBER: measuring_station.station_id, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required( + CONF_LOCATION, + ): LocationSelector(), + } + ), + user_input + or { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + }, + ), + errors=errors, + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Handle importing from yaml.""" + await self.async_set_unique_id(str(import_config[CONF_STATION_NUMBER])) + try: + self._abort_if_unique_id_configured() + except AbortFlow as exc: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_already_configured", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="deprecated_yaml_import_issue_already_configured", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + raise exc + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "World Air Quality Index", + }, + ) + return self.async_create_entry( + title=import_config[CONF_NAME], + data={ + CONF_API_KEY: import_config[CONF_API_KEY], + CONF_STATION_NUMBER: import_config[CONF_STATION_NUMBER], + }, + ) diff --git a/homeassistant/components/waqi/const.py b/homeassistant/components/waqi/const.py new file mode 100644 index 00000000000..2847a29b8ad --- /dev/null +++ b/homeassistant/components/waqi/const.py @@ -0,0 +1,10 @@ +"""Constants for the World Air Quality Index (WAQI) integration.""" +import logging + +DOMAIN = "waqi" + +LOGGER = logging.getLogger(__package__) + +CONF_STATION_NUMBER = "station_number" + +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=waqi"} diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py new file mode 100644 index 00000000000..b7beef8fda9 --- /dev/null +++ b/homeassistant/components/waqi/coordinator.py @@ -0,0 +1,36 @@ +"""Coordinator for the World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +from datetime import timedelta + +from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER + + +class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): + """The WAQI Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, client: WAQIClient) -> None: + """Initialize the WAQI data coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + ) + self._client = client + + async def _async_update_data(self) -> WAQIAirQuality: + try: + return await self._client.get_by_station_number( + self.config_entry.data[CONF_STATION_NUMBER] + ) + except WAQIError as exc: + raise UpdateFailed from exc diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index 2022558a500..bf31fb570a8 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -1,7 +1,8 @@ { "domain": "waqi", "name": "World Air Quality Index (WAQI)", - "codeowners": ["@andrey-git"], + "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["waqiasync"], diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 51b9acb8e59..0ad295ca5af 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,10 +1,9 @@ """Support for the World Air Quality Index service.""" from __future__ import annotations -from datetime import timedelta import logging -from aiowaqi import WAQIAirQuality, WAQIClient, WAQIConnectionError, WAQISearchResult +from aiowaqi import WAQIAuthenticationError, WAQIClient, WAQIConnectionError import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,10 +11,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_TEMPERATURE, ATTR_TIME, + CONF_API_KEY, + CONF_NAME, CONF_TOKEN, ) from homeassistant.core import HomeAssistant @@ -23,7 +25,12 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER +from .coordinator import WAQIDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,8 +50,6 @@ ATTR_ICON = "mdi:cloud" CONF_LOCATIONS = "locations" CONF_STATIONS = "stations" -SCAN_INTERVAL = timedelta(minutes=5) - TIMEOUT = 10 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( @@ -70,102 +75,126 @@ async def async_setup_platform( client = WAQIClient(session=async_get_clientsession(hass), request_timeout=TIMEOUT) client.authenticate(token) - dev = [] + station_count = 0 try: for location_name in locations: stations = await client.search(location_name) _LOGGER.debug("The following stations were returned: %s", stations) for station in stations: - waqi_sensor = WaqiSensor(client, station) + station_count = station_count + 1 if not station_filter or { - waqi_sensor.uid, - waqi_sensor.url, - waqi_sensor.station_name, + station.station_id, + station.station.external_url, + station.station.name, } & set(station_filter): - dev.append(waqi_sensor) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_NUMBER: station.station_id, + CONF_NAME: station.station.name, + CONF_API_KEY: config[CONF_TOKEN], + }, + ) + ) + except WAQIAuthenticationError as err: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_invalid_auth", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_invalid_auth", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + _LOGGER.exception("Could not authenticate with WAQI") + raise PlatformNotReady from err except WAQIConnectionError as err: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders=ISSUE_PLACEHOLDER, + ) _LOGGER.exception("Failed to connect to WAQI servers") raise PlatformNotReady from err - async_add_entities(dev, True) + if station_count == 0: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_none_found", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_none_found", + translation_placeholders=ISSUE_PLACEHOLDER, + ) -class WaqiSensor(SensorEntity): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the WAQI sensor.""" + coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([WaqiSensor(coordinator)]) + + +class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): """Implementation of a WAQI sensor.""" _attr_icon = ATTR_ICON _attr_device_class = SensorDeviceClass.AQI _attr_state_class = SensorStateClass.MEASUREMENT - _data: WAQIAirQuality | None = None - - def __init__(self, client: WAQIClient, search_result: WAQISearchResult) -> None: + def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: """Initialize the sensor.""" - self._client = client - self.uid = search_result.station_id - self.url = search_result.station.external_url - self.station_name = search_result.station.name - - @property - def name(self): - """Return the name of the sensor.""" - if self.station_name: - return f"WAQI {self.station_name}" - return f"WAQI {self.url if self.url else self.uid}" + super().__init__(coordinator) + self._attr_name = f"WAQI {self.coordinator.data.city.name}" + self._attr_unique_id = str(coordinator.data.station_id) @property def native_value(self) -> int | None: """Return the state of the device.""" - assert self._data - return self._data.air_quality_index - - @property - def available(self): - """Return sensor availability.""" - return self._data is not None - - @property - def unique_id(self): - """Return unique ID.""" - return self.uid + return self.coordinator.data.air_quality_index @property def extra_state_attributes(self): """Return the state attributes of the last update.""" attrs = {} + try: + attrs[ATTR_ATTRIBUTION] = " and ".join( + [ATTRIBUTION] + + [ + attribution.name + for attribution in self.coordinator.data.attributions + ] + ) - if self._data is not None: - try: - attrs[ATTR_ATTRIBUTION] = " and ".join( - [ATTRIBUTION] - + [attribution.name for attribution in self._data.attributions] - ) + attrs[ATTR_TIME] = self.coordinator.data.measured_at + attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant - attrs[ATTR_TIME] = self._data.measured_at - attrs[ATTR_DOMINENTPOL] = self._data.dominant_pollutant + iaqi = self.coordinator.data.extended_air_quality - iaqi = self._data.extended_air_quality - - attribute = { - ATTR_PM2_5: iaqi.pm25, - ATTR_PM10: iaqi.pm10, - ATTR_HUMIDITY: iaqi.humidity, - ATTR_PRESSURE: iaqi.pressure, - ATTR_TEMPERATURE: iaqi.temperature, - ATTR_OZONE: iaqi.ozone, - ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, - ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, - } - res_attributes = {k: v for k, v in attribute.items() if v is not None} - return {**attrs, **res_attributes} - except (IndexError, KeyError): - return {ATTR_ATTRIBUTION: ATTRIBUTION} - - async def async_update(self) -> None: - """Get the latest data and updates the states.""" - if self.uid: - result = await self._client.get_by_station_number(self.uid) - elif self.url: - result = await self._client.get_by_name(self.url) - else: - result = None - self._data = result + attribute = { + ATTR_PM2_5: iaqi.pm25, + ATTR_PM10: iaqi.pm10, + ATTR_HUMIDITY: iaqi.humidity, + ATTR_PRESSURE: iaqi.pressure, + ATTR_TEMPERATURE: iaqi.temperature, + ATTR_OZONE: iaqi.ozone, + ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, + ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, + } + res_attributes = {k: v for k, v in attribute.items() if v is not None} + return {**attrs, **res_attributes} + except (IndexError, KeyError): + return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json new file mode 100644 index 00000000000..4ceb911de9e --- /dev/null +++ b/homeassistant/components/waqi/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "description": "Select a location to get the closest measuring station.", + "data": { + "location": "[%key:common::config_flow::data::location%]", + "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%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The World Air Quality Index YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The WAQI YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to WAQI works and restart Home Assistant to try again or remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_already_configured": { + "title": "The WAQI YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but the measuring station was already imported when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_none_found": { + "title": "The WAQI YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but there weren't any stations imported because they couldn't be found.\n\nEnsure the imported configuration is correct and remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6c992fd4b5e..0f55df7cc99 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -516,6 +516,7 @@ FLOWS = { "volvooncall", "vulcan", "wallbox", + "waqi", "watttime", "waze_travel_time", "webostv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 379dd112672..5eaf1b8d0a4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6288,7 +6288,7 @@ "waqi": { "name": "World Air Quality Index (WAQI)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "waterfurnace": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94a48d0793e..9ea3661450c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,6 +347,9 @@ aiovlc==0.1.0 # homeassistant.components.vodafone_station aiovodafone==0.1.0 +# homeassistant.components.waqi +aiowaqi==0.2.1 + # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py new file mode 100644 index 00000000000..b6f36680ee3 --- /dev/null +++ b/tests/components/waqi/__init__.py @@ -0,0 +1 @@ +"""Tests for the World Air Quality Index (WAQI) integration.""" diff --git a/tests/components/waqi/conftest.py b/tests/components/waqi/conftest.py new file mode 100644 index 00000000000..176c1e27d8f --- /dev/null +++ b/tests/components/waqi/conftest.py @@ -0,0 +1,30 @@ +"""Common fixtures for the World Air Quality Index (WAQI) tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.waqi.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="4584", + title="de Jongweg, Utrecht", + data={CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584}, + ) diff --git a/tests/components/waqi/fixtures/air_quality_sensor.json b/tests/components/waqi/fixtures/air_quality_sensor.json new file mode 100644 index 00000000000..49f1184822f --- /dev/null +++ b/tests/components/waqi/fixtures/air_quality_sensor.json @@ -0,0 +1,160 @@ +{ + "aqi": 29, + "idx": 4584, + "attributions": [ + { + "url": "http://www.luchtmeetnet.nl/", + "name": "RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit", + "logo": "Netherland-RIVM.png" + }, + { + "url": "https://waqi.info/", + "name": "World Air Quality Index Project" + } + ], + "city": { + "geo": [52.105031, 5.124464], + "name": "de Jongweg, Utrecht", + "url": "https://aqicn.org/city/netherland/utrecht/de-jongweg", + "location": "" + }, + "dominentpol": "o3", + "iaqi": { + "h": { + "v": 80 + }, + "no2": { + "v": 2.3 + }, + "o3": { + "v": 29.4 + }, + "p": { + "v": 1008.8 + }, + "pm10": { + "v": 12 + }, + "pm25": { + "v": 17 + }, + "t": { + "v": 16 + }, + "w": { + "v": 1.4 + }, + "wg": { + "v": 2.4 + } + }, + "time": { + "s": "2023-08-07 17:00:00", + "tz": "+02:00", + "v": 1691427600, + "iso": "2023-08-07T17:00:00+02:00" + }, + "forecast": { + "daily": { + "o3": [ + { + "avg": 28, + "day": "2023-08-07", + "max": 34, + "min": 25 + }, + { + "avg": 22, + "day": "2023-08-08", + "max": 29, + "min": 19 + }, + { + "avg": 23, + "day": "2023-08-09", + "max": 35, + "min": 9 + }, + { + "avg": 18, + "day": "2023-08-10", + "max": 38, + "min": 3 + }, + { + "avg": 17, + "day": "2023-08-11", + "max": 17, + "min": 11 + } + ], + "pm10": [ + { + "avg": 8, + "day": "2023-08-07", + "max": 10, + "min": 6 + }, + { + "avg": 9, + "day": "2023-08-08", + "max": 12, + "min": 6 + }, + { + "avg": 9, + "day": "2023-08-09", + "max": 13, + "min": 6 + }, + { + "avg": 23, + "day": "2023-08-10", + "max": 33, + "min": 10 + }, + { + "avg": 27, + "day": "2023-08-11", + "max": 34, + "min": 27 + } + ], + "pm25": [ + { + "avg": 19, + "day": "2023-08-07", + "max": 29, + "min": 11 + }, + { + "avg": 25, + "day": "2023-08-08", + "max": 37, + "min": 19 + }, + { + "avg": 27, + "day": "2023-08-09", + "max": 45, + "min": 19 + }, + { + "avg": 64, + "day": "2023-08-10", + "max": 86, + "min": 33 + }, + { + "avg": 72, + "day": "2023-08-11", + "max": 89, + "min": 72 + } + ] + } + }, + "debug": { + "sync": "2023-08-08T01:29:52+09:00" + } +} diff --git a/tests/components/waqi/fixtures/search_result.json b/tests/components/waqi/fixtures/search_result.json new file mode 100644 index 00000000000..65da5abc09a --- /dev/null +++ b/tests/components/waqi/fixtures/search_result.json @@ -0,0 +1,32 @@ +[ + { + "uid": 6332, + "aqi": "27", + "time": { + "tz": "+02:00", + "stime": "2023-08-08 15:00:00", + "vtime": 1691499600 + }, + "station": { + "name": "Griftpark, Utrecht", + "geo": [52.101308, 5.128183], + "url": "netherland/utrecht/griftpark", + "country": "NL" + } + }, + { + "uid": 4584, + "aqi": "27", + "time": { + "tz": "+02:00", + "stime": "2023-08-08 15:00:00", + "vtime": 1691499600 + }, + "station": { + "name": "de Jongweg, Utrecht", + "geo": [52.105031, 5.124464], + "url": "netherland/utrecht/de-jongweg", + "country": "NL" + } + } +] diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py new file mode 100644 index 00000000000..3901ffad550 --- /dev/null +++ b/tests/components/waqi/test_config_flow.py @@ -0,0 +1,108 @@ +"""Test the World Air Quality Index (WAQI) config flow.""" +import json +from unittest.mock import AsyncMock, patch + +from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import load_fixture + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_full_flow(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 + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + CONF_API_KEY: "asd", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "de Jongweg, Utrecht" + assert result["data"] == { + CONF_API_KEY: "asd", + CONF_STATION_NUMBER: 4584, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (WAQIAuthenticationError(), "invalid_auth"), + (WAQIConnectionError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test we handle errors during configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + CONF_API_KEY: "asd", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + CONF_API_KEY: "asd", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py new file mode 100644 index 00000000000..18f77028a29 --- /dev/null +++ b/tests/components/waqi/test_sensor.py @@ -0,0 +1,124 @@ +"""Test the World Air Quality Index (WAQI) sensor.""" +import json +from unittest.mock import patch + +from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult + +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_NAME, + CONF_PLATFORM, + CONF_TOKEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + +LEGACY_CONFIG = { + Platform.SENSOR: [ + { + CONF_PLATFORM: DOMAIN, + CONF_TOKEN: "asd", + CONF_LOCATIONS: ["utrecht"], + CONF_STATIONS: [6332], + } + ] +} + + +async def test_legacy_migration(hass: HomeAssistant) -> None: + """Test migration from yaml to config flow.""" + search_result_json = json.loads(load_fixture("waqi/search_result.json")) + search_results = [ + WAQISearchResult.parse_obj(search_result) + for search_result in search_result_json + ] + with patch( + "aiowaqi.WAQIClient.search", + return_value=search_results, + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_legacy_migration_already_imported( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test migration from yaml to config flow after already imported.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + assert state.state == "29" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_NUMBER: 4584, + CONF_NAME: "xyz", + CONF_API_KEY: "asd", + }, + ) + ) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Test failed update.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + assert state.state == "29" + + +async def test_updating_failed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test failed update.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + side_effect=WAQIError(), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY From 71726130c31ff8863f3731f849ce4fdd65d6a9ba Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 9 Sep 2023 12:12:14 -0400 Subject: [PATCH 313/984] Add binary sensors to Roborock (#99990) * init binary sensors commit * add binary sensors * fix test * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/roborock/binary_sensor.py | 110 ++++++++++++++++++ homeassistant/components/roborock/const.py | 1 + .../components/roborock/strings.json | 11 ++ .../components/roborock/test_binary_sensor.py | 17 +++ 4 files changed, 139 insertions(+) create mode 100644 homeassistant/components/roborock/binary_sensor.py create mode 100644 tests/components/roborock/test_binary_sensor.py diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py new file mode 100644 index 00000000000..d61c1ee14b9 --- /dev/null +++ b/homeassistant/components/roborock/binary_sensor.py @@ -0,0 +1,110 @@ +"""Support for Roborock sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from roborock.roborock_typing import DeviceProp + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity + + +@dataclass +class RoborockBinarySensorDescriptionMixin: + """A class that describes binary sensor entities.""" + + value_fn: Callable[[DeviceProp], bool] + + +@dataclass +class RoborockBinarySensorDescription( + BinarySensorEntityDescription, RoborockBinarySensorDescriptionMixin +): + """A class that describes Roborock binary sensors.""" + + +BINARY_SENSOR_DESCRIPTIONS = [ + RoborockBinarySensorDescription( + key="dry_status", + translation_key="mop_drying_status", + icon="mdi:heat-wave", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.dry_status, + ), + RoborockBinarySensorDescription( + key="water_box_carriage_status", + translation_key="mop_attached", + icon="mdi:square-rounded", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_box_carriage_status, + ), + RoborockBinarySensorDescription( + key="water_box_status", + translation_key="water_box_attached", + icon="mdi:water", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_box_status, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock vacuum binary sensors.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockBinarySensorEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in BINARY_SENSOR_DESCRIPTIONS + if description.value_fn(coordinator.roborock_device_info.props) is not None + ) + + +class RoborockBinarySensorEntity(RoborockCoordinatedEntity, BinarySensorEntity): + """Representation of a Roborock binary sensor.""" + + entity_description: RoborockBinarySensorDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + description: RoborockBinarySensorDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(unique_id, coordinator) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the value reported by the sensor.""" + return bool( + self.entity_description.value_fn( + self.coordinator.roborock_device_info.props + ) + ) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 2fc59134d14..36078e53b3e 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -13,4 +13,5 @@ PLATFORMS = [ Platform.SWITCH, Platform.TIME, Platform.NUMBER, + Platform.BINARY_SENSOR, ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 5ca2292f804..269bbf04cf2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -27,6 +27,17 @@ } }, "entity": { + "binary_sensor": { + "mop_attached": { + "name": "Mop attached" + }, + "mop_drying_status": { + "name": "Mop drying" + }, + "water_box_attached": { + "name": "Water box attached" + } + }, "number": { "volume": { "name": "Volume" diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py new file mode 100644 index 00000000000..d4d415424bc --- /dev/null +++ b/tests/components/roborock/test_binary_sensor.py @@ -0,0 +1,17 @@ +"""Test Roborock Binary Sensor.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_binary_sensors( + hass: HomeAssistant, setup_entry: MockConfigEntry +) -> None: + """Test binary sensors and check test values are correctly set.""" + assert len(hass.states.async_all("binary_sensor")) == 2 + assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" + assert ( + hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state + == "on" + ) From 743ce463117dcf06cb00a285aa90c67dab72e6e1 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 9 Sep 2023 18:34:01 +0200 Subject: [PATCH 314/984] Deprecate CLOSE_COMM_ON_ERROR (#99946) --- homeassistant/components/modbus/modbus.py | 20 +++++++++++++++++++- homeassistant/components/modbus/strings.json | 6 ++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index fdb7be3d3cf..238df4466c4 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -34,6 +34,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType @@ -255,6 +256,24 @@ class ModbusHub: def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" + if CONF_CLOSE_COMM_ON_ERROR in client_config: + async_create_issue( # pragma: no cover + hass, + DOMAIN, + "deprecated_close_comm_config", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_close_comm_config", + translation_placeholders={ + "config_key": "close_comm_on_error", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be remove in version 2024.4" + ) # generic configuration self._client: ModbusBaseClient | None = None self._async_cancel_listener: Callable[[], None] | None = None @@ -274,7 +293,6 @@ class ModbusHub: self._pb_params = { "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], - "reset_socket": client_config[CONF_CLOSE_COMM_ON_ERROR], "retries": client_config[CONF_RETRIES], "retry_on_empty": client_config[CONF_RETRY_ON_EMPTY], } diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 61694074d79..780757a3eeb 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -68,5 +68,11 @@ } } } + }, + "issues": { + "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." + } } } From 868fdd81da2687bd382aec642121539026d7929c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 18:48:09 +0200 Subject: [PATCH 315/984] Add entity translations to withings (#99194) * Add entity translations to Withings * Add entity translations to Withings --- .../components/withings/binary_sensor.py | 2 +- homeassistant/components/withings/common.py | 4 +- homeassistant/components/withings/sensor.py | 63 ++++++------ .../components/withings/strings.json | 99 +++++++++++++++++++ 4 files changed, 132 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 6b072030bda..e1351d7c019 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -36,7 +36,7 @@ BINARY_SENSORS = [ key=Measurement.IN_BED.value, measurement=Measurement.IN_BED, measure_type=NotifyAppli.BED_IN, - name="In bed", + translation_key="in_bed", icon="mdi:bed", update_type=UpdateType.WEBHOOK, device_class=BinarySensorDeviceClass.OCCUPANCY, diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 17e3c551bcc..76124cfff91 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -548,6 +548,7 @@ class BaseWithingsSensor(Entity): _attr_should_poll = False entity_description: WithingsEntityDescription + _attr_has_entity_name = True def __init__( self, data_manager: DataManager, description: WithingsEntityDescription @@ -555,9 +556,6 @@ class BaseWithingsSensor(Entity): """Initialize the Withings sensor.""" self._data_manager = data_manager self.entity_description = description - self._attr_name = ( - f"Withings {description.measurement.value} {data_manager.profile}" - ) self._attr_unique_id = get_attribute_unique_id( description, data_manager.user_id ) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index c2cdd89a17f..4f98daacc42 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -51,7 +51,6 @@ SENSORS = [ key=Measurement.WEIGHT_KG.value, measurement=Measurement.WEIGHT_KG, measure_type=MeasureType.WEIGHT, - name="Weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -61,7 +60,7 @@ SENSORS = [ key=Measurement.FAT_MASS_KG.value, measurement=Measurement.FAT_MASS_KG, measure_type=MeasureType.FAT_MASS_WEIGHT, - name="Fat Mass", + translation_key="fat_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -71,7 +70,7 @@ SENSORS = [ key=Measurement.FAT_FREE_MASS_KG.value, measurement=Measurement.FAT_FREE_MASS_KG, measure_type=MeasureType.FAT_FREE_MASS, - name="Fat Free Mass", + translation_key="fat_free_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -81,7 +80,7 @@ SENSORS = [ key=Measurement.MUSCLE_MASS_KG.value, measurement=Measurement.MUSCLE_MASS_KG, measure_type=MeasureType.MUSCLE_MASS, - name="Muscle Mass", + translation_key="muscle_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -91,7 +90,7 @@ SENSORS = [ key=Measurement.BONE_MASS_KG.value, measurement=Measurement.BONE_MASS_KG, measure_type=MeasureType.BONE_MASS, - name="Bone Mass", + translation_key="bone_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -101,7 +100,7 @@ SENSORS = [ key=Measurement.HEIGHT_M.value, measurement=Measurement.HEIGHT_M, measure_type=MeasureType.HEIGHT, - name="Height", + translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, @@ -112,7 +111,6 @@ SENSORS = [ key=Measurement.TEMP_C.value, measurement=Measurement.TEMP_C, measure_type=MeasureType.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -122,7 +120,7 @@ SENSORS = [ key=Measurement.BODY_TEMP_C.value, measurement=Measurement.BODY_TEMP_C, measure_type=MeasureType.BODY_TEMPERATURE, - name="Body Temperature", + translation_key="body_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -132,7 +130,7 @@ SENSORS = [ key=Measurement.SKIN_TEMP_C.value, measurement=Measurement.SKIN_TEMP_C, measure_type=MeasureType.SKIN_TEMPERATURE, - name="Skin Temperature", + translation_key="skin_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -142,7 +140,7 @@ SENSORS = [ key=Measurement.FAT_RATIO_PCT.value, measurement=Measurement.FAT_RATIO_PCT, measure_type=MeasureType.FAT_RATIO, - name="Fat Ratio", + translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -151,7 +149,7 @@ SENSORS = [ key=Measurement.DIASTOLIC_MMHG.value, measurement=Measurement.DIASTOLIC_MMHG, measure_type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, - name="Diastolic Blood Pressure", + translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -160,7 +158,7 @@ SENSORS = [ key=Measurement.SYSTOLIC_MMGH.value, measurement=Measurement.SYSTOLIC_MMGH, measure_type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, - name="Systolic Blood Pressure", + translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -169,7 +167,7 @@ SENSORS = [ key=Measurement.HEART_PULSE_BPM.value, measurement=Measurement.HEART_PULSE_BPM, measure_type=MeasureType.HEART_RATE, - name="Heart Pulse", + translation_key="heart_pulse", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -179,7 +177,7 @@ SENSORS = [ key=Measurement.SPO2_PCT.value, measurement=Measurement.SPO2_PCT, measure_type=MeasureType.SP02, - name="SP02", + translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -188,7 +186,7 @@ SENSORS = [ key=Measurement.HYDRATION.value, measurement=Measurement.HYDRATION, measure_type=MeasureType.HYDRATION, - name="Hydration", + translation_key="hydration", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, icon="mdi:water", @@ -200,7 +198,7 @@ SENSORS = [ key=Measurement.PWV.value, measurement=Measurement.PWV, measure_type=MeasureType.PULSE_WAVE_VELOCITY, - name="Pulse Wave Velocity", + translation_key="pulse_wave_velocity", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -210,7 +208,7 @@ SENSORS = [ key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, measure_type=GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - name="Breathing disturbances intensity", + translation_key="breathing_disturbances_intensity", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -219,7 +217,7 @@ SENSORS = [ key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_DEEP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DEEP_SLEEP_DURATION, - name="Deep sleep", + translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -231,7 +229,7 @@ SENSORS = [ key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DURATION_TO_SLEEP, - name="Time to sleep", + translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -243,7 +241,7 @@ SENSORS = [ key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DURATION_TO_WAKEUP, - name="Time to wakeup", + translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, @@ -255,7 +253,7 @@ SENSORS = [ key=Measurement.SLEEP_HEART_RATE_AVERAGE.value, measurement=Measurement.SLEEP_HEART_RATE_AVERAGE, measure_type=GetSleepSummaryField.HR_AVERAGE, - name="Average heart rate", + translation_key="average_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -266,6 +264,7 @@ SENSORS = [ key=Measurement.SLEEP_HEART_RATE_MAX.value, measurement=Measurement.SLEEP_HEART_RATE_MAX, measure_type=GetSleepSummaryField.HR_MAX, + translation_key="fat_mass", name="Maximum heart rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", @@ -277,7 +276,7 @@ SENSORS = [ key=Measurement.SLEEP_HEART_RATE_MIN.value, measurement=Measurement.SLEEP_HEART_RATE_MIN, measure_type=GetSleepSummaryField.HR_MIN, - name="Minimum heart rate", + translation_key="maximum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -288,7 +287,7 @@ SENSORS = [ key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value, measurement=Measurement.SLEEP_LIGHT_DURATION_SECONDS, measure_type=GetSleepSummaryField.LIGHT_SLEEP_DURATION, - name="Light sleep", + translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -300,7 +299,7 @@ SENSORS = [ key=Measurement.SLEEP_REM_DURATION_SECONDS.value, measurement=Measurement.SLEEP_REM_DURATION_SECONDS, measure_type=GetSleepSummaryField.REM_SLEEP_DURATION, - name="REM sleep", + translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -312,7 +311,7 @@ SENSORS = [ key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, measure_type=GetSleepSummaryField.RR_AVERAGE, - name="Average respiratory rate", + translation_key="average_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -322,7 +321,7 @@ SENSORS = [ key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_MAX, measure_type=GetSleepSummaryField.RR_MAX, - name="Maximum respiratory rate", + translation_key="maximum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -332,7 +331,7 @@ SENSORS = [ key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_MIN, measure_type=GetSleepSummaryField.RR_MIN, - name="Minimum respiratory rate", + translation_key="minimum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -342,7 +341,7 @@ SENSORS = [ key=Measurement.SLEEP_SCORE.value, measurement=Measurement.SLEEP_SCORE, measure_type=GetSleepSummaryField.SLEEP_SCORE, - name="Sleep score", + translation_key="sleep_score", native_unit_of_measurement=SCORE_POINTS, icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, @@ -353,7 +352,7 @@ SENSORS = [ key=Measurement.SLEEP_SNORING.value, measurement=Measurement.SLEEP_SNORING, measure_type=GetSleepSummaryField.SNORING, - name="Snoring", + translation_key="snoring", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -362,7 +361,7 @@ SENSORS = [ key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value, measurement=Measurement.SLEEP_SNORING_EPISODE_COUNT, measure_type=GetSleepSummaryField.SNORING_EPISODE_COUNT, - name="Snoring episode count", + translation_key="snoring_episode_count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -371,7 +370,7 @@ SENSORS = [ key=Measurement.SLEEP_WAKEUP_COUNT.value, measurement=Measurement.SLEEP_WAKEUP_COUNT, measure_type=GetSleepSummaryField.WAKEUP_COUNT, - name="Wakeup count", + translation_key="wakeup_count", native_unit_of_measurement=UOM_FREQUENCY, icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, @@ -382,7 +381,7 @@ SENSORS = [ key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_WAKEUP_DURATION_SECONDS, measure_type=GetSleepSummaryField.WAKEUP_DURATION, - name="Wakeup time", + translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 8f8a32c95e7..424a0edadce 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -27,5 +27,104 @@ "create_entry": { "default": "Successfully authenticated with Withings." } + }, + "entity": { + "binary_sensor": { + "in_bed": { + "name": "In bed" + } + }, + "sensor": { + "fat_mass": { + "name": "Fat mass" + }, + "fat_free_mass": { + "name": "Fat free mass" + }, + "muscle_mass": { + "name": "Muscle mass" + }, + "bone_mass": { + "name": "Bone mass" + }, + "height": { + "name": "Height" + }, + "body_temperature": { + "name": "Body temperature" + }, + "skin_temperature": { + "name": "Skin temperature" + }, + "fat_ratio": { + "name": "Fat ratio" + }, + "diastolic_blood_pressure": { + "name": "Diastolic blood pressure" + }, + "systolic_blood_pressure": { + "name": "Systolic blood pressure" + }, + "heart_pulse": { + "name": "Heart pulse" + }, + "spo2": { + "name": "SpO2" + }, + "hydration": { + "name": "Hydration" + }, + "pulse_wave_velocity": { + "name": "Pulse wave velocity" + }, + "breathing_disturbances_intensity": { + "name": "Breathing disturbances intensity" + }, + "deep_sleep": { + "name": "Deep sleep" + }, + "time_to_sleep": { + "name": "Time to sleep" + }, + "time_to_wakeup": { + "name": "Time to wakeup" + }, + "average_heart_rate": { + "name": "Average heart rate" + }, + "maximum_heart_rate": { + "name": "Maximum heart rate" + }, + "light_sleep": { + "name": "Light sleep" + }, + "rem_sleep": { + "name": "REM sleep" + }, + "average_respiratory_rate": { + "name": "Average respiratory rate" + }, + "maximum_respiratory_rate": { + "name": "Maximum respiratory rate" + }, + "minimum_respiratory_rate": { + "name": "Minimum respiratory rate" + }, + "sleep_score": { + "name": "Sleep score" + }, + "snoring": { + "name": "Snoring" + }, + "snoring_episode_count": { + "name": "Snoring episode count" + }, + "wakeup_count": { + "name": "Wakeup count" + }, + "wakeup_time": { + "name": "Wakeup time" + } + } } } From a62ffeaa9983f5ce67242328ea8a3b4c8c054e5f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 19:11:28 +0200 Subject: [PATCH 316/984] Decouple Withings sensor tests from yaml (#99691) * Decouple Withings sensor tests from yaml * Fix feedback * Add pytest fixture * Update tests/components/withings/test_sensor.py Co-authored-by: G Johansson * Update snapshots * Update snapshots --------- Co-authored-by: G Johansson --- tests/components/withings/__init__.py | 84 ++ tests/components/withings/conftest.py | 345 ++++- .../withings/snapshots/test_sensor.ambr | 1254 +++++++++++++++++ tests/components/withings/test_sensor.py | 355 +---- 4 files changed, 1727 insertions(+), 311 deletions(-) create mode 100644 tests/components/withings/snapshots/test_sensor.ambr diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index c1caac222a5..e148c1a2c84 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1 +1,85 @@ """Tests for the withings component.""" +from collections.abc import Iterable +from typing import Any, Optional +from urllib.parse import urlparse + +import arrow +from withings_api import DateType +from withings_api.common import ( + GetSleepSummaryField, + MeasureGetMeasGroupCategory, + MeasureGetMeasResponse, + MeasureType, + SleepGetSummaryResponse, + UserGetDeviceResponse, +) + +from homeassistant.components.webhook import async_generate_url +from homeassistant.core import HomeAssistant + +from .common import ProfileConfig, WebhookResponse + + +async def call_webhook( + hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client +) -> WebhookResponse: + """Call the webhook.""" + webhook_url = async_generate_url(hass, webhook_id) + + resp = await client.post( + urlparse(webhook_url).path, + data=data, + ) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data: dict[str, Any] = await resp.json() + resp.close() + + return WebhookResponse(message=data["message"], message_code=data["code"]) + + +class MockWithings: + """Mock object for Withings.""" + + def __init__(self, user_profile: ProfileConfig): + """Initialize mock.""" + self.api_response_user_get_device = user_profile.api_response_user_get_device + self.api_response_measure_get_meas = user_profile.api_response_measure_get_meas + self.api_response_sleep_get_summary = ( + user_profile.api_response_sleep_get_summary + ) + + def user_get_device(self) -> UserGetDeviceResponse: + """Get devices.""" + if isinstance(self.api_response_user_get_device, Exception): + raise self.api_response_user_get_device + return self.api_response_user_get_device + + def measure_get_meas( + self, + meastype: MeasureType | None = None, + category: MeasureGetMeasGroupCategory | None = None, + startdate: DateType | None = None, + enddate: DateType | None = None, + offset: int | None = None, + lastupdate: DateType | None = None, + ) -> MeasureGetMeasResponse: + """Get measurements.""" + if isinstance(self.api_response_measure_get_meas, Exception): + raise self.api_response_measure_get_meas + return self.api_response_measure_get_meas + + def sleep_get_summary( + self, + data_fields: Iterable[GetSleepSummaryField], + startdateymd: Optional[DateType] = arrow.utcnow(), + enddateymd: Optional[DateType] = arrow.utcnow(), + offset: Optional[int] = None, + lastupdate: Optional[DateType] = arrow.utcnow(), + ) -> SleepGetSummaryResponse: + """Get sleep.""" + if isinstance(self.api_response_sleep_get_summary, Exception): + raise self.api_response_sleep_get_summary + return self.api_response_sleep_get_summary diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 887a9b8179b..510fc980dc7 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -1,15 +1,276 @@ """Fixtures for tests.""" - +from collections.abc import Awaitable, Callable, Coroutine +import time +from typing import Any from unittest.mock import patch +import arrow import pytest +from withings_api.common import ( + GetSleepSummaryData, + GetSleepSummarySerie, + MeasureGetMeasGroup, + MeasureGetMeasGroupAttrib, + MeasureGetMeasGroupCategory, + MeasureGetMeasMeasure, + MeasureGetMeasResponse, + MeasureType, + SleepGetSummaryResponse, + SleepModel, +) +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.withings.const import DOMAIN +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from .common import ComponentFactory +from . import MockWithings +from .common import ComponentFactory, new_profile_config +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +ComponentSetup = Callable[[], Awaitable[MockWithings]] + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +SCOPES = [ + "user.info", + "user.metrics", + "user.activity", + "user.sleepevents", +] +TITLE = "henk" +WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + +PERSON0 = new_profile_config( + profile="12345", + user_id=12345, + api_response_measure_get_meas=MeasureGetMeasResponse( + measuregrps=( + MeasureGetMeasGroup( + attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, + category=MeasureGetMeasGroupCategory.REAL, + created=arrow.utcnow().shift(hours=-1), + date=arrow.utcnow().shift(hours=-1), + deviceid="DEV_ID", + grpid=1, + measures=( + MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70), + MeasureGetMeasMeasure( + type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_FREE_MASS, unit=0, value=60 + ), + MeasureGetMeasMeasure( + type=MeasureType.MUSCLE_MASS, unit=0, value=50 + ), + MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10), + MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2), + MeasureGetMeasMeasure( + type=MeasureType.TEMPERATURE, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.BODY_TEMPERATURE, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_RATIO, unit=-3, value=70 + ), + MeasureGetMeasMeasure( + type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70 + ), + MeasureGetMeasMeasure( + type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100 + ), + MeasureGetMeasMeasure( + type=MeasureType.HEART_RATE, unit=0, value=60 + ), + MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95), + MeasureGetMeasMeasure( + type=MeasureType.HYDRATION, unit=-2, value=95 + ), + MeasureGetMeasMeasure( + type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100 + ), + ), + ), + MeasureGetMeasGroup( + attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, + category=MeasureGetMeasGroupCategory.REAL, + created=arrow.utcnow().shift(hours=-2), + date=arrow.utcnow().shift(hours=-2), + deviceid="DEV_ID", + grpid=1, + measures=( + MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), + MeasureGetMeasMeasure( + type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=51 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_FREE_MASS, unit=0, value=61 + ), + MeasureGetMeasMeasure( + type=MeasureType.MUSCLE_MASS, unit=0, value=51 + ), + MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), + MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=21), + MeasureGetMeasMeasure( + type=MeasureType.TEMPERATURE, unit=0, value=41 + ), + MeasureGetMeasMeasure( + type=MeasureType.BODY_TEMPERATURE, unit=0, value=41 + ), + MeasureGetMeasMeasure( + type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_RATIO, unit=-3, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 + ), + MeasureGetMeasMeasure( + type=MeasureType.HEART_RATE, unit=0, value=61 + ), + MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=96), + MeasureGetMeasMeasure( + type=MeasureType.HYDRATION, unit=-2, value=96 + ), + MeasureGetMeasMeasure( + type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=101 + ), + ), + ), + MeasureGetMeasGroup( + attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, + category=MeasureGetMeasGroupCategory.REAL, + created=arrow.utcnow(), + date=arrow.utcnow(), + deviceid="DEV_ID", + grpid=1, + measures=( + MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), + MeasureGetMeasMeasure( + type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_FREE_MASS, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.MUSCLE_MASS, unit=0, value=51 + ), + MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), + MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201), + MeasureGetMeasMeasure( + type=MeasureType.TEMPERATURE, unit=0, value=41 + ), + MeasureGetMeasMeasure( + type=MeasureType.BODY_TEMPERATURE, unit=0, value=34 + ), + MeasureGetMeasMeasure( + type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_RATIO, unit=-3, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 + ), + MeasureGetMeasMeasure( + type=MeasureType.HEART_RATE, unit=0, value=61 + ), + MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98), + MeasureGetMeasMeasure( + type=MeasureType.HYDRATION, unit=-2, value=96 + ), + MeasureGetMeasMeasure( + type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102 + ), + ), + ), + ), + more=False, + timezone=dt_util.UTC, + updatetime=arrow.get("2019-08-01"), + offset=0, + ), + api_response_sleep_get_summary=SleepGetSummaryResponse( + more=False, + offset=0, + series=( + GetSleepSummarySerie( + timezone=dt_util.UTC, + model=SleepModel.SLEEP_MONITOR, + startdate=arrow.get("2019-02-01"), + enddate=arrow.get("2019-02-01"), + date=arrow.get("2019-02-01"), + modified=arrow.get(12345), + data=GetSleepSummaryData( + breathing_disturbances_intensity=110, + deepsleepduration=111, + durationtosleep=112, + durationtowakeup=113, + hr_average=114, + hr_max=115, + hr_min=116, + lightsleepduration=117, + remsleepduration=118, + rr_average=119, + rr_max=120, + rr_min=121, + sleep_score=122, + snoring=123, + snoringepisodecount=124, + wakeupcount=125, + wakeupduration=126, + ), + ), + GetSleepSummarySerie( + timezone=dt_util.UTC, + model=SleepModel.SLEEP_MONITOR, + startdate=arrow.get("2019-02-01"), + enddate=arrow.get("2019-02-01"), + date=arrow.get("2019-02-01"), + modified=arrow.get(12345), + data=GetSleepSummaryData( + breathing_disturbances_intensity=210, + deepsleepduration=211, + durationtosleep=212, + durationtowakeup=213, + hr_average=214, + hr_max=215, + hr_min=216, + lightsleepduration=217, + remsleepduration=218, + rr_average=219, + rr_max=220, + rr_min=221, + sleep_score=222, + snoring=223, + snoringepisodecount=224, + wakeupcount=225, + wakeupduration=226, + ), + ), + ), + ), +) + @pytest.fixture def component_factory( @@ -25,3 +286,83 @@ def component_factory( yield ComponentFactory( hass, api_class_mock, hass_client_no_auth, aioclient_mock ) + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return SCOPES + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="12345", + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": "12345", + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "use_webhook": True, + "webhook_id": WEBHOOK_ID, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Callable[[], Coroutine[Any, Any, MockWithings]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) + + async def func() -> MockWithings: + mock = MockWithings(PERSON0) + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + return mock + + return func diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6aa9e5b3784 --- /dev/null +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -0,0 +1,1254 @@ +# serializer version: 1 +# name: test_all_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Weight', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_weight', + 'last_changed': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_all_entities.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass', + 'last_changed': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_all_entities.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Diastolic blood pressure', + 'state_class': , + 'unit_of_measurement': 'mmhg', + }), + 'context': , + 'entity_id': 'sensor.henk_diastolic_blood_pressure', + 'last_changed': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_all_entities.11 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Systolic blood pressure', + 'state_class': , + 'unit_of_measurement': 'mmhg', + }), + 'context': , + 'entity_id': 'sensor.henk_systolic_blood_pressure', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_all_entities.12 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Heart pulse', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_heart_pulse', + 'last_changed': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities.13 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk SpO2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_spo2', + 'last_changed': , + 'last_updated': , + 'state': '0.95', + }) +# --- +# name: test_all_entities.14 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Hydration', + 'icon': 'mdi:water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_hydration', + 'last_changed': , + 'last_updated': , + 'state': '0.95', + }) +# --- +# name: test_all_entities.15 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'henk Pulse wave velocity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_pulse_wave_velocity', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_all_entities.16 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Breathing disturbances intensity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_breathing_disturbances_intensity', + 'last_changed': , + 'last_updated': , + 'state': '160.0', + }) +# --- +# name: test_all_entities.17 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Deep sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_deep_sleep', + 'last_changed': , + 'last_updated': , + 'state': '322', + }) +# --- +# name: test_all_entities.18 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_sleep', + 'last_changed': , + 'last_updated': , + 'state': '162.0', + }) +# --- +# name: test_all_entities.19 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to wakeup', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_wakeup', + 'last_changed': , + 'last_updated': , + 'state': '163.0', + }) +# --- +# name: test_all_entities.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass', + 'last_changed': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities.20 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Average heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_average_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '164.0', + }) +# --- +# name: test_all_entities.21 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Fat mass', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass_2', + 'last_changed': , + 'last_updated': , + 'state': '165.0', + }) +# --- +# name: test_all_entities.22 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '166.0', + }) +# --- +# name: test_all_entities.23 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Light sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_light_sleep', + 'last_changed': , + 'last_updated': , + 'state': '334', + }) +# --- +# name: test_all_entities.24 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk REM sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_rem_sleep', + 'last_changed': , + 'last_updated': , + 'state': '336', + }) +# --- +# name: test_all_entities.25 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Average respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_average_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '169.0', + }) +# --- +# name: test_all_entities.26 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '170.0', + }) +# --- +# name: test_all_entities.27 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Minimum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_minimum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '171.0', + }) +# --- +# name: test_all_entities.28 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Sleep score', + 'icon': 'mdi:medal', + 'state_class': , + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.henk_sleep_score', + 'last_changed': , + 'last_updated': , + 'state': '222', + }) +# --- +# name: test_all_entities.29 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Snoring', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_snoring', + 'last_changed': , + 'last_updated': , + 'state': '173.0', + }) +# --- +# name: test_all_entities.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass', + 'last_changed': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_all_entities.30 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Snoring episode count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_snoring_episode_count', + 'last_changed': , + 'last_updated': , + 'state': '348', + }) +# --- +# name: test_all_entities.31 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Wakeup count', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': 'times', + }), + 'context': , + 'entity_id': 'sensor.henk_wakeup_count', + 'last_changed': , + 'last_updated': , + 'state': '350', + }) +# --- +# name: test_all_entities.32 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Wakeup time', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_wakeup_time', + 'last_changed': , + 'last_updated': , + 'state': '176.0', + }) +# --- +# name: test_all_entities.33 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_breathing_disturbances_intensity henk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_breathing_disturbances_intensity_henk', + 'last_changed': , + 'last_updated': , + 'state': '160.0', + }) +# --- +# name: test_all_entities.34 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_deep_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep', + 'original_name': 'Withings sleep_deep_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_deep_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.35 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_deep_duration_seconds henk', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_deep_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '322', + }) +# --- +# name: test_all_entities.36 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_tosleep_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep', + 'original_name': 'Withings sleep_tosleep_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.37 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_tosleep_duration_seconds henk', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_tosleep_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '162.0', + }) +# --- +# name: test_all_entities.38 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_towakeup_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep-off', + 'original_name': 'Withings sleep_towakeup_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.39 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_towakeup_duration_seconds henk', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_towakeup_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '163.0', + }) +# --- +# name: test_all_entities.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Bone mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_bone_mass', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_all_entities.40 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_heart_rate_average_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart-pulse', + 'original_name': 'Withings sleep_heart_rate_average_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_heart_rate_average_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities.41 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_heart_rate_average_bpm henk', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_heart_rate_average_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '164.0', + }) +# --- +# name: test_all_entities.42 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_heart_rate_max_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart-pulse', + 'original_name': 'Withings sleep_heart_rate_max_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_heart_rate_max_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities.43 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_heart_rate_max_bpm henk', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_heart_rate_max_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '165.0', + }) +# --- +# name: test_all_entities.44 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_heart_rate_min_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart-pulse', + 'original_name': 'Withings sleep_heart_rate_min_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_heart_rate_min_bpm', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities.45 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_heart_rate_min_bpm henk', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_heart_rate_min_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '166.0', + }) +# --- +# name: test_all_entities.46 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_light_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep', + 'original_name': 'Withings sleep_light_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_light_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.47 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_light_duration_seconds henk', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_light_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '334', + }) +# --- +# name: test_all_entities.48 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_rem_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep', + 'original_name': 'Withings sleep_rem_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_rem_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.49 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_rem_duration_seconds henk', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_rem_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '336', + }) +# --- +# name: test_all_entities.5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Height', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_height', + 'last_changed': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_all_entities.50 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_respiratory_average_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_respiratory_average_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_respiratory_average_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities.51 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_respiratory_average_bpm henk', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_respiratory_average_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '169.0', + }) +# --- +# name: test_all_entities.52 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_respiratory_max_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_respiratory_max_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_respiratory_max_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities.53 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_respiratory_max_bpm henk', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_respiratory_max_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '170.0', + }) +# --- +# name: test_all_entities.54 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_respiratory_min_bpm_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_respiratory_min_bpm henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_respiratory_min_bpm', + 'unit_of_measurement': 'br/min', + }) +# --- +# name: test_all_entities.55 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_respiratory_min_bpm henk', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_respiratory_min_bpm_henk', + 'last_changed': , + 'last_updated': , + 'state': '171.0', + }) +# --- +# name: test_all_entities.56 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_score_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:medal', + 'original_name': 'Withings sleep_score henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_score', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_all_entities.57 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_score henk', + 'icon': 'mdi:medal', + 'state_class': , + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_score_henk', + 'last_changed': , + 'last_updated': , + 'state': '222', + }) +# --- +# name: test_all_entities.58 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_snoring_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_snoring henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_snoring', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities.59 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_snoring henk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_snoring_henk', + 'last_changed': , + 'last_updated': , + 'state': '173.0', + }) +# --- +# name: test_all_entities.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities.60 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_snoring_eposode_count_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Withings sleep_snoring_eposode_count henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_snoring_eposode_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities.61 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_snoring_eposode_count henk', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_snoring_eposode_count_henk', + 'last_changed': , + 'last_updated': , + 'state': '348', + }) +# --- +# name: test_all_entities.62 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_wakeup_count_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sleep-off', + 'original_name': 'Withings sleep_wakeup_count henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_wakeup_count', + 'unit_of_measurement': 'times', + }) +# --- +# name: test_all_entities.63 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Withings sleep_wakeup_count henk', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': 'times', + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_wakeup_count_henk', + 'last_changed': , + 'last_updated': , + 'state': '350', + }) +# --- +# name: test_all_entities.64 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.withings_sleep_wakeup_duration_seconds_henk', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sleep-off', + 'original_name': 'Withings sleep_wakeup_duration_seconds henk', + 'platform': 'withings', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities.65 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Withings sleep_wakeup_duration_seconds henk', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.withings_sleep_wakeup_duration_seconds_henk', + 'last_changed': , + 'last_updated': , + 'state': '176.0', + }) +# --- +# name: test_all_entities.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Body temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_body_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Skin temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_skin_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_all_entities.9 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Fat ratio', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_fat_ratio', + 'last_changed': , + 'last_updated': , + 'state': '0.07', + }) +# --- diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6c4bb867f75..07fcb8fedaa 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -2,20 +2,9 @@ from typing import Any from unittest.mock import patch -import arrow -from withings_api.common import ( - GetSleepSummaryData, - GetSleepSummarySerie, - MeasureGetMeasGroup, - MeasureGetMeasGroupAttrib, - MeasureGetMeasGroupCategory, - MeasureGetMeasMeasure, - MeasureGetMeasResponse, - MeasureType, - NotifyAppli, - SleepGetSummaryResponse, - SleepModel, -) +import pytest +from syrupy import SnapshotAssertion +from withings_api.common import NotifyAppli from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.withings.common import WithingsEntityDescription @@ -24,236 +13,17 @@ from homeassistant.components.withings.sensor import SENSORS from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from homeassistant.util import dt as dt_util -from .common import ComponentFactory, async_get_entity_id, new_profile_config +from . import MockWithings, call_webhook +from .common import async_get_entity_id +from .conftest import PERSON0, WEBHOOK_ID, ComponentSetup + +from tests.typing import ClientSessionGenerator WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { attr.measurement: attr for attr in SENSORS } -PERSON0 = new_profile_config( - "person0", - 0, - api_response_measure_get_meas=MeasureGetMeasResponse( - measuregrps=( - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-1), - date=arrow.utcnow().shift(hours=-1), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=60 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=50 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=60 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=95 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-2), - date=arrow.utcnow().shift(hours=-2), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=51 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=61 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=21), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=96), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=101 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow(), - date=arrow.utcnow(), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=34 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102 - ), - ), - ), - ), - more=False, - timezone=dt_util.UTC, - updatetime=arrow.get("2019-08-01"), - offset=0, - ), - api_response_sleep_get_summary=SleepGetSummaryResponse( - more=False, - offset=0, - series=( - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - breathing_disturbances_intensity=110, - deepsleepduration=111, - durationtosleep=112, - durationtowakeup=113, - hr_average=114, - hr_max=115, - hr_min=116, - lightsleepduration=117, - remsleepduration=118, - rr_average=119, - rr_max=120, - rr_min=121, - sleep_score=122, - snoring=123, - snoringepisodecount=124, - wakeupcount=125, - wakeupduration=126, - ), - ), - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - breathing_disturbances_intensity=210, - deepsleepduration=211, - durationtosleep=212, - durationtowakeup=213, - hr_average=214, - hr_max=215, - hr_min=216, - lightsleepduration=217, - remsleepduration=218, - rr_average=219, - rr_max=220, - rr_min=221, - sleep_score=222, - snoring=223, - snoringepisodecount=224, - wakeupcount=225, - wakeupduration=226, - ), - ), - ), - ), -) EXPECTED_DATA = ( (PERSON0, Measurement.WEIGHT_KG, 70.0), @@ -304,79 +74,22 @@ def async_assert_state_equals( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_default_enabled_entities( hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, + setup_integration: ComponentSetup, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" + await setup_integration() entity_registry: EntityRegistry = er.async_get(hass) - await component_factory.configure_component(profile_configs=(PERSON0,)) - - # Assert entities should not exist yet. - for attribute in SENSORS: - assert not await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - - # person 0 - await component_factory.setup_profile(PERSON0.user_id) - - # Assert entities should exist. - for attribute in SENSORS: - entity_id = await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - assert entity_id - assert entity_registry.async_is_registered(entity_id) - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP) - assert resp.message_code == 0 - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT) - assert resp.message_code == 0 - - for person, measurement, expected in EXPECTED_DATA: - attribute = WITHINGS_MEASUREMENTS_MAP[measurement] - entity_id = await async_get_entity_id( - hass, attribute, person.user_id, SENSOR_DOMAIN - ) - state_obj = hass.states.get(entity_id) - - if attribute.entity_registry_enabled_default: - async_assert_state_equals(entity_id, state_obj, expected, attribute) - else: - assert state_obj is None - - # Unload - await component_factory.unload(PERSON0) - - -async def test_all_entities( - hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, -) -> None: - """Test all entities.""" - entity_registry: EntityRegistry = er.async_get(hass) - + mock = MockWithings(PERSON0) with patch( - "homeassistant.components.withings.sensor.BaseWithingsSensor.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - - await component_factory.configure_component(profile_configs=(PERSON0,)) - - # Assert entities should not exist yet. - for attribute in SENSORS: - assert not await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - - # person 0 - await component_factory.setup_profile(PERSON0.user_id) - + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + client = await hass_client_no_auth() # Assert entities should exist. for attribute in SENSORS: entity_id = await async_get_entity_id( @@ -384,11 +97,21 @@ async def test_all_entities( ) assert entity_id assert entity_registry.async_is_registered(entity_id) - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP) + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": PERSON0.user_id, "appli": NotifyAppli.SLEEP}, + client, + ) + assert resp.message_code == 0 + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": PERSON0.user_id, "appli": NotifyAppli.WEIGHT}, + client, + ) assert resp.message_code == 0 - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT) assert resp.message_code == 0 for person, measurement, expected in EXPECTED_DATA: @@ -400,5 +123,19 @@ async def test_all_entities( async_assert_state_equals(entity_id, state_obj, expected, attribute) - # Unload - await component_factory.unload(PERSON0) + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, setup_integration: ComponentSetup, snapshot: SnapshotAssertion +) -> None: + """Test all entities.""" + await setup_integration() + + mock = MockWithings(PERSON0) + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + for sensor in SENSORS: + entity_id = await async_get_entity_id(hass, sensor, 12345, SENSOR_DOMAIN) + assert hass.states.get(entity_id) == snapshot From 862506af61e8c064f0859683d535cb9c940069fa Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sat, 9 Sep 2023 19:16:27 +0200 Subject: [PATCH 317/984] Bump pywaze to 0.4.0 (#99995) bump pywaze from 0.3.0 to 0.4.0 --- homeassistant/components/waze_travel_time/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 3f1f8c6d67b..c72d9b1dbad 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", "iot_class": "cloud_polling", "loggers": ["pywaze", "homeassistant.helpers.location"], - "requirements": ["pywaze==0.3.0"] + "requirements": ["pywaze==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 89dbf774fe7..b617b773adf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2232,7 +2232,7 @@ pyvlx==0.2.20 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.3.0 +pywaze==0.4.0 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ea3661450c..4427dd4c1a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1646,7 +1646,7 @@ pyvizio==0.1.61 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.3.0 +pywaze==0.4.0 # homeassistant.components.html5 pywebpush==1.9.2 From aff49cb67abc376d18a58347f13542bfbad81b6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 12:23:15 -0500 Subject: [PATCH 318/984] Bump bluetooth-auto-recovery to 1.2.3 (#99979) fixes #99977 --- 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 d6753adf3c4..e5df324ec02 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,7 +17,7 @@ "bleak==0.21.1", "bleak-retry-connector==3.1.3", "bluetooth-adapters==0.16.1", - "bluetooth-auto-recovery==1.2.2", + "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", "dbus-fast==2.0.1" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3c6be4df133..0857591e120 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ bcrypt==4.0.1 bleak-retry-connector==3.1.3 bleak==0.21.1 bluetooth-adapters==0.16.1 -bluetooth-auto-recovery==1.2.2 +bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index b617b773adf..6c5e57e8f83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -544,7 +544,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.2 +bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth # homeassistant.components.esphome diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4427dd4c1a4..9596bb81d3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -458,7 +458,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.2 +bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth # homeassistant.components.esphome From 23f4ccd4f11ddfad3874b98e14cdac15f18761e8 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 9 Sep 2023 22:32:13 +0200 Subject: [PATCH 319/984] Fix late review findings in Minecraft Server (#99865) --- homeassistant/components/minecraft_server/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index a13196dffc6..ee8bdbe2a3f 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -77,9 +77,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # 1 --> 2: Use config entry ID as base for unique IDs. if config_entry.version == 1: - assert config_entry.unique_id - assert config_entry.entry_id old_unique_id = config_entry.unique_id + assert old_unique_id config_entry_id = config_entry.entry_id # Migrate config entry. @@ -94,7 +93,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate entities. await er.async_migrate_entries(hass, config_entry_id, _migrate_entity_unique_id) - _LOGGER.info("Migration to version %s successful", config_entry.version) + _LOGGER.debug("Migration to version %s successful", config_entry.version) return True @@ -108,7 +107,6 @@ async def _async_migrate_device_identifiers( for device_entry in dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ): - assert device_entry for identifier in device_entry.identifiers: if identifier[1] == old_unique_id: # Device found in registry. Update identifiers. @@ -138,7 +136,6 @@ async def _async_migrate_device_identifiers( @callback def _migrate_entity_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: """Migrate the unique ID of an entity to the new format.""" - assert entity_entry # Different variants of unique IDs are available in version 1: # 1) SRV record: '-srv-' From eeaca8ae3c53ad2787b7336f8653b3a9454495ca Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 23:18:41 +0200 Subject: [PATCH 320/984] Use shorthand attributes in Vicare (#99915) --- .../components/vicare/binary_sensor.py | 22 ++--- homeassistant/components/vicare/button.py | 18 ++-- homeassistant/components/vicare/climate.py | 97 +++++------------- homeassistant/components/vicare/sensor.py | 12 +-- .../components/vicare/water_heater.py | 99 ++++--------------- 5 files changed, 60 insertions(+), 188 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 89e8bec42d1..5aa76dc9962 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -196,23 +196,18 @@ class ViCareBinarySensor(BinarySensorEntity): self._api = api self.entity_description = description self._device_config = device_config - self._state = None - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), manufacturer="Viessmann", - model=self._device_config.getModel(), + model=device_config.getModel(), configuration_url="https://developer.viessmann.com/", ) @property def available(self): """Return True if entity is available.""" - return self._state is not None + return self._attr_is_on is not None @property def unique_id(self) -> str: @@ -224,16 +219,11 @@ class ViCareBinarySensor(BinarySensorEntity): return f"{tmp_id}-{self._api.id}" return tmp_id - @property - def is_on(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self.entity_description.value_getter(self._api) + self._attr_is_on = self.entity_description.value_getter(self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index ac025ff37d1..7fd8cccd3a4 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -104,6 +104,13 @@ class ViCareButton(ButtonEntity): self.entity_description = description self._device_config = device_config self._api = api + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), + manufacturer="Viessmann", + model=device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) def press(self) -> None: """Handle the button press.""" @@ -119,17 +126,6 @@ class ViCareButton(ButtonEntity): except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), - manufacturer="Viessmann", - model=self._device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) - @property def unique_id(self) -> str: """Return unique ID for this device.""" diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index d5beff4b268..a9188adc964 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -36,13 +36,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_HEATING_TYPE, - DOMAIN, - VICARE_API, - VICARE_DEVICE_CONFIG, - VICARE_NAME, -) +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME _LOGGER = logging.getLogger(__name__) @@ -126,7 +120,6 @@ async def async_setup_entry( api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - config_entry.data[CONF_HEATING_TYPE], ) entities.append(entity) @@ -149,35 +142,26 @@ class ViCareClimate(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = VICARE_TEMP_HEATING_MIN + _attr_max_temp = VICARE_TEMP_HEATING_MAX + _attr_target_temperature_step = PRECISION_WHOLE + _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) - def __init__(self, name, api, circuit, device_config, heating_type): + def __init__(self, name, api, circuit, device_config): """Initialize the climate device.""" - self._name = name - self._state = None + self._attr_name = name self._api = api self._circuit = circuit - self._device_config = device_config self._attributes = {} - self._target_temperature = None self._current_mode = None - self._current_temperature = None self._current_program = None - self._heating_type = heating_type self._current_action = None - - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - return f"{self._device_config.getConfig().serial}-{self._circuit.id}" - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), + self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), manufacturer="Viessmann", - model=self._device_config.getModel(), + model=device_config.getModel(), configuration_url="https://developer.viessmann.com/", ) @@ -193,27 +177,29 @@ class ViCareClimate(ClimateEntity): _supply_temperature = self._circuit.getSupplyTemperature() if _room_temperature is not None: - self._current_temperature = _room_temperature + self._attr_current_temperature = _room_temperature elif _supply_temperature is not None: - self._current_temperature = _supply_temperature + self._attr_current_temperature = _supply_temperature else: - self._current_temperature = None + self._attr_current_temperature = None with suppress(PyViCareNotSupportedFeatureError): self._current_program = self._circuit.getActiveProgram() with suppress(PyViCareNotSupportedFeatureError): - self._target_temperature = self._circuit.getCurrentDesiredTemperature() + self._attr_target_temperature = ( + self._circuit.getCurrentDesiredTemperature() + ) with suppress(PyViCareNotSupportedFeatureError): self._current_mode = self._circuit.getActiveMode() # Update the generic device attributes - self._attributes = {} - - self._attributes["room_temperature"] = _room_temperature - self._attributes["active_vicare_program"] = self._current_program - self._attributes["active_vicare_mode"] = self._current_mode + self._attributes = { + "room_temperature": _room_temperature, + "active_vicare_program": self._current_program, + "active_vicare_mode": self._current_mode, + } with suppress(PyViCareNotSupportedFeatureError): self._attributes[ @@ -248,21 +234,6 @@ class ViCareClimate(ClimateEntity): except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @property - def name(self): - """Return the name of the climate device.""" - return self._name - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - @property def hvac_mode(self) -> HVACMode | None: """Return current hvac mode.""" @@ -313,37 +284,17 @@ class ViCareClimate(ClimateEntity): return HVACAction.HEATING return HVACAction.IDLE - @property - def min_temp(self): - """Return the minimum temperature.""" - return VICARE_TEMP_HEATING_MIN - - @property - def max_temp(self): - """Return the maximum temperature.""" - return VICARE_TEMP_HEATING_MAX - - @property - def target_temperature_step(self) -> float: - """Set target temperature step to wholes.""" - return PRECISION_WHOLE - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: self._circuit.setProgramTemperature(self._current_program, temp) - self._target_temperature = temp + self._attr_target_temperature = temp @property def preset_mode(self): """Return the current preset mode, e.g., home, away, temp.""" return VICARE_TO_HA_PRESET_HEATING.get(self._current_program) - @property - def preset_modes(self): - """Return the available preset mode.""" - return list(HA_TO_VICARE_PRESET_HEATING) - def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode and deactivate any existing programs.""" vicare_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 24f23b0da0a..d7ac7f25274 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -673,7 +673,6 @@ class ViCareSensor(SensorEntity): self._attr_name = name self._api = api self._device_config = device_config - self._state = None @property def device_info(self) -> DeviceInfo: @@ -689,7 +688,7 @@ class ViCareSensor(SensorEntity): @property def available(self): """Return True if entity is available.""" - return self._state is not None + return self._attr_native_value is not None @property def unique_id(self) -> str: @@ -701,16 +700,13 @@ class ViCareSensor(SensorEntity): return f"{tmp_id}-{self._api.id}" return tmp_id - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self.entity_description.value_getter(self._api) + self._attr_native_value = self.entity_description.value_getter( + self._api + ) if self.entity_description.unit_getter: vicare_unit = self.entity_description.unit_getter(self._api) diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index c0d77dd46b6..3357d2e0a31 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -15,23 +15,12 @@ from homeassistant.components.water_heater import ( WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - PRECISION_TENTHS, - PRECISION_WHOLE, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_HEATING_TYPE, - DOMAIN, - VICARE_API, - VICARE_DEVICE_CONFIG, - VICARE_NAME, -) +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME _LOGGER = logging.getLogger(__name__) @@ -95,7 +84,6 @@ async def async_setup_entry( api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - config_entry.data[CONF_HEATING_TYPE], ) entities.append(entity) @@ -107,30 +95,37 @@ class ViCareWater(WaterHeaterEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = VICARE_TEMP_WATER_MIN + _attr_max_temp = VICARE_TEMP_WATER_MAX + _attr_operation_list = list(HA_TO_VICARE_HVAC_DHW) - def __init__(self, name, api, circuit, device_config, heating_type): + def __init__(self, name, api, circuit, device_config): """Initialize the DHW water_heater device.""" - self._name = name - self._state = None + self._attr_name = name self._api = api self._circuit = circuit - self._device_config = device_config self._attributes = {} - self._target_temperature = None - self._current_temperature = None self._current_mode = None - self._heating_type = heating_type + self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), + manufacturer="Viessmann", + model=device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._current_temperature = ( + self._attr_current_temperature = ( self._api.getDomesticHotWaterStorageTemperature() ) with suppress(PyViCareNotSupportedFeatureError): - self._target_temperature = ( + self._attr_target_temperature = ( self._api.getDomesticHotWaterDesiredTemperature() ) @@ -146,69 +141,13 @@ class ViCareWater(WaterHeaterEntity): except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - return f"{self._device_config.getConfig().serial}-{self._circuit.id}" - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), - manufacturer="Viessmann", - model=self._device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) - - @property - def name(self): - """Return the name of the water_heater device.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return UnitOfTemperature.CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: self._api.setDomesticHotWaterTemperature(temp) - self._target_temperature = temp - - @property - def min_temp(self): - """Return the minimum temperature.""" - return VICARE_TEMP_WATER_MIN - - @property - def max_temp(self): - """Return the maximum temperature.""" - return VICARE_TEMP_WATER_MAX - - @property - def target_temperature_step(self) -> float: - """Set target temperature step to wholes.""" - return PRECISION_WHOLE + self._attr_target_temperature = temp @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" return VICARE_TO_HA_HVAC_DHW.get(self._current_mode) - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return list(HA_TO_VICARE_HVAC_DHW) From af8fd6c2d9101ff299d293ed09f1df66f02c983c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 23:22:03 +0200 Subject: [PATCH 321/984] Restore airtouch4 codeowner (#99984) --- CODEOWNERS | 2 ++ homeassistant/components/airtouch4/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index ba792b07183..b4eb1e39072 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,8 @@ build.json @home-assistant/supervisor /tests/components/airthings/ @danielhiversen /homeassistant/components/airthings_ble/ @vincegio @LaStrada /tests/components/airthings_ble/ @vincegio @LaStrada +/homeassistant/components/airtouch4/ @samsinnamon +/tests/components/airtouch4/ @samsinnamon /homeassistant/components/airvisual/ @bachya /tests/components/airvisual/ @bachya /homeassistant/components/airvisual_pro/ @bachya diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json index e845c278a54..8a1f947af64 100644 --- a/homeassistant/components/airtouch4/manifest.json +++ b/homeassistant/components/airtouch4/manifest.json @@ -1,7 +1,7 @@ { "domain": "airtouch4", "name": "AirTouch 4", - "codeowners": [], + "codeowners": ["@samsinnamon"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airtouch4", "iot_class": "local_polling", From 081d0bdce59c3a0dcdeb8968350a57abfb8b9fc8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 9 Sep 2023 23:50:26 +0200 Subject: [PATCH 322/984] Bump plugwise to v0.32.2 (#99973) * Bump plugwise to v0.32.2 * Adapt number.py to the backend updates * Update related test-cases * Update plugwise test-fixtures * Update test_diagnostics.py --- .../components/plugwise/manifest.json | 2 +- homeassistant/components/plugwise/number.py | 13 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../plugwise/fixtures/adam_jip/all_data.json | 48 ++ .../all_data.json | 54 ++ .../anna_heatpump_heating/all_data.json | 6 + .../fixtures/m_adam_cooling/all_data.json | 12 + .../fixtures/m_adam_heating/all_data.json | 12 + .../m_anna_heatpump_cooling/all_data.json | 6 + .../m_anna_heatpump_idle/all_data.json | 6 + .../fixtures/p1v4_442_triple/all_data.json | 8 +- .../p1v4_442_triple/notifications.json | 6 +- .../fixtures/stretch_v31/all_data.json | 9 - tests/components/plugwise/test_diagnostics.py | 594 ++++++++++-------- tests/components/plugwise/test_number.py | 4 +- 16 files changed, 493 insertions(+), 291 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ef0f01b38f7..e87e1f0c281 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.31.9"], + "requirements": ["plugwise==0.32.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 5979480d90f..6fd3f7f92da 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -27,7 +27,7 @@ from .entity import PlugwiseEntity class PlugwiseEntityDescriptionMixin: """Mixin values for Plugwise entities.""" - command: Callable[[Smile, str, float], Awaitable[None]] + command: Callable[[Smile, str, str, float], Awaitable[None]] @dataclass @@ -43,7 +43,9 @@ NUMBER_TYPES = ( PlugwiseNumberEntityDescription( key="maximum_boiler_temperature", translation_key="maximum_boiler_temperature", - command=lambda api, number, value: api.set_number_setpoint(number, value), + command=lambda api, number, dev_id, value: api.set_number_setpoint( + number, dev_id, value + ), device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -51,7 +53,9 @@ NUMBER_TYPES = ( PlugwiseNumberEntityDescription( key="max_dhw_temperature", translation_key="max_dhw_temperature", - command=lambda api, number, value: api.set_number_setpoint(number, value), + command=lambda api, number, dev_id, value: api.set_number_setpoint( + number, dev_id, value + ), device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -94,6 +98,7 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): ) -> None: """Initiate Plugwise Number.""" super().__init__(coordinator, device_id) + self.device_id = device_id self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" self._attr_mode = NumberMode.BOX @@ -109,6 +114,6 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Change to the new setpoint value.""" await self.entity_description.command( - self.coordinator.api, self.entity_description.key, value + self.coordinator.api, self.entity_description.key, self.device_id, value ) await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 6c5e57e8f83..021ef7989b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1439,7 +1439,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.9 +plugwise==0.32.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9596bb81d3f..66be97e6ae0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.9 +plugwise==0.32.2 # 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 177478f0fff..4dda9af3b54 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -20,6 +20,12 @@ "setpoint": 13.0, "temperature": 24.2 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -43,6 +49,12 @@ "temperature_difference": 2.0, "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": "ABCD012345670A07" }, @@ -60,6 +72,12 @@ "temperature_difference": 1.7, "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": "ABCD012345670A05" }, @@ -99,6 +117,12 @@ "setpoint": 13.0, "temperature": 30.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -122,6 +146,12 @@ "temperature_difference": 1.8, "valve_position": 100 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A09" }, @@ -145,6 +175,12 @@ "setpoint": 13.0, "temperature": 30.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -187,6 +223,12 @@ "temperature_difference": 1.9, "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": "ABCD012345670A04" }, @@ -246,6 +288,12 @@ "setpoint": 9.0, "temperature": 27.4 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.01, 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 63f0012ea92..0cc28731ff4 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 @@ -95,6 +95,12 @@ "temperature_difference": -0.4, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A17" }, @@ -123,6 +129,12 @@ "setpoint": 15.0, "temperature": 17.2 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -200,6 +212,12 @@ "temperature_difference": -0.2, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A09" }, @@ -217,6 +235,12 @@ "temperature_difference": 3.5, "valve_position": 100 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A02" }, @@ -245,6 +269,12 @@ "setpoint": 21.5, "temperature": 20.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -289,6 +319,12 @@ "temperature_difference": 0.1, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A10" }, @@ -317,6 +353,12 @@ "setpoint": 13.0, "temperature": 16.5 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -353,6 +395,12 @@ "temperature_difference": 0.0, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -387,6 +435,12 @@ "setpoint": 14.0, "temperature": 18.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 49b5221233f..cdddfdb3439 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -76,6 +76,12 @@ "setpoint": 20.5, "temperature": 19.3 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.1, 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 92618a90189..ac7e602821e 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -40,6 +40,12 @@ "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" }, @@ -118,6 +124,12 @@ "setpoint_low": 20.0, "temperature": 239 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, 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 4345cf76a3a..a4923b1c549 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -45,6 +45,12 @@ "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" }, @@ -114,6 +120,12 @@ "setpoint": 15.0, "temperature": 17.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index 20f2db213bd..f98f253e938 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -78,6 +78,12 @@ "setpoint_low": 20.5, "temperature": 26.3 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.1, diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 3a7bd2dae89..56d26f67acb 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -78,6 +78,12 @@ "setpoint_low": 20.5, "temperature": 23.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.1, 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 e9a3b4c68b9..d503bd3a59d 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -2,7 +2,7 @@ "devices": { "03e65b16e4b247a29ae0d75a78cb492e": { "binary_sensors": { - "plugwise_notification": false + "plugwise_notification": true }, "dev_class": "gateway", "firmware": "4.4.2", @@ -51,7 +51,11 @@ }, "gateway": { "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", - "notifications": {}, + "notifications": { + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } + }, "smile_name": "Smile P1" } } diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json b/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json index 0967ef424bc..49db062035a 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json @@ -1 +1,5 @@ -{} +{ + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } +} diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index c336a9cb9c2..8604aaae10e 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -48,15 +48,6 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A07" }, - "71e1944f2a944b26ad73323e399efef0": { - "dev_class": "switching", - "members": ["5ca521ac179d468e91d772eeeb8a2117"], - "model": "Switchgroup", - "name": "Test", - "switches": { - "relay": true - } - }, "aac7b735042c4832ac9ff33aae4f453b": { "dev_class": "dishwasher", "firmware": "2011-06-27T10:52:18+02:00", diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index 5dde8a0e09e..69f180692e2 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -31,159 +31,141 @@ async def test_diagnostics( }, }, "devices": { - "df4a4a8169904cdb9c03d61a21f42140": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Lisa", - "name": "Zone Lisa Bios", - "zigbee_mac_address": "ABCD012345670A06", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 13.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, - }, - "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "None", - "last_used": "Badkamer Schema", - "mode": "heat", - "sensors": {"temperature": 16.5, "setpoint": 13.0, "battery": 67}, - }, - "b310b72a0e354bfab43089919b9a88bf": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Tom/Floor", - "name": "Floor kraan", - "zigbee_mac_address": "ABCD012345670A02", - "vendor": "Plugwise", + "02cf28bfec924855854c544690a609ef": { "available": True, + "dev_class": "vcr", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "NVR", "sensors": { - "temperature": 26.0, - "setpoint": 21.5, - "temperature_difference": 3.5, - "valve_position": 100, + "electricity_consumed": 34.0, + "electricity_consumed_interval": 9.15, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0, }, - }, - "a2c3583e0a6349358998b760cea82d2a": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Tom/Floor", - "name": "Bios Cv Thermostatic Radiator ", - "zigbee_mac_address": "ABCD012345670A09", + "switches": {"lock": True, "relay": True}, "vendor": "Plugwise", - "available": True, - "sensors": { - "temperature": 17.2, - "setpoint": 13.0, - "battery": 62, - "temperature_difference": -0.2, - "valve_position": 0.0, - }, - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "dev_class": "zone_thermostat", - "firmware": "2016-08-02T02:00:00+02:00", - "hardware": "255", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Lisa", - "name": "Zone Lisa WK", - "zigbee_mac_address": "ABCD012345670A07", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 21.5, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, - }, - "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "home", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "GF7 Woonkamer", - "last_used": "GF7 Woonkamer", - "mode": "auto", - "sensors": {"temperature": 20.9, "setpoint": 21.5, "battery": 34}, - }, - "fe799307f1624099878210aa0b9f1475": { - "dev_class": "gateway", - "firmware": "3.0.15", - "hardware": "AME Smile 2.0 board", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "mac_address": "012345670001", - "model": "Gateway", - "name": "Adam", - "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise", - "select_regulation_mode": "heating", - "binary_sensors": {"plugwise_notification": True}, - "sensors": {"outdoor_temperature": 7.81}, - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Jessie", - "zigbee_mac_address": "ABCD012345670A10", - "vendor": "Plugwise", - "available": True, - "sensors": { - "temperature": 17.1, - "setpoint": 15.0, - "battery": 62, - "temperature_difference": 0.1, - "valve_position": 0.0, - }, + "zigbee_mac_address": "ABCD012345670A15", }, "21f2b542c49845e6bb416884c55778d6": { + "available": True, "dev_class": "game_console", "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", "name": "Playstation Smart Plug", - "zigbee_mac_address": "ABCD012345670A12", - "vendor": "Plugwise", - "available": True, "sensors": { "electricity_consumed": 82.6, "electricity_consumed_interval": 8.6, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, }, - "switches": {"relay": True, "lock": False}, + "switches": {"lock": False, "relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A12", + }, + "4a810418d5394b3f82727340b91ba740": { + "available": True, + "dev_class": "router", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "USG Smart Plug", + "sensors": { + "electricity_consumed": 8.5, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0, + }, + "switches": {"lock": True, "relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A16", + }, + "675416a629f343c495449970e2ca37b5": { + "available": True, + "dev_class": "router", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "Ziggo Modem", + "sensors": { + "electricity_consumed": 12.2, + "electricity_consumed_interval": 2.97, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0, + }, + "switches": {"lock": True, "relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01", + }, + "680423ff840043738f42cc7f1ff97a36": { + "available": True, + "dev_class": "thermo_sensor", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "08963fec7c53423ca5680aa4cb502c63", + "model": "Tom/Floor", + "name": "Thermostatic Radiator Badkamer", + "sensors": { + "battery": 51, + "setpoint": 14.0, + "temperature": 19.1, + "temperature_difference": -0.4, + "valve_position": 0.0, + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A17", + }, + "6a3bf693d05e48e0b460c815a4fdd09d": { + "active_preset": "asleep", + "available": True, + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + ], + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "last_used": "CV Jessie", + "location": "82fa13f017d240daa0d0ea1775420f24", + "mode": "auto", + "model": "Lisa", + "name": "Zone Thermostat Jessie", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "CV Jessie", + "sensors": {"battery": 37, "setpoint": 15.0, "temperature": 17.2}, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03", }, "78d1126fc4c743db81b61c20e88342a7": { + "available": True, "dev_class": "central_heating_pump", "firmware": "2019-06-21T02:00:00+02:00", "location": "c50f167537524366a5af7aa3942feb1e", "model": "Plug", "name": "CV Pomp", - "zigbee_mac_address": "ABCD012345670A05", - "vendor": "Plugwise", - "available": True, "sensors": { "electricity_consumed": 35.6, "electricity_consumed_interval": 7.37, @@ -191,153 +173,88 @@ async def test_diagnostics( "electricity_produced_interval": 0.0, }, "switches": {"relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05", }, "90986d591dcd426cae3ec3e8111ff730": { + "binary_sensors": {"heating_state": True}, "dev_class": "heater_central", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", "model": "Unknown", "name": "OnOff", - "binary_sensors": {"heating_state": True}, "sensors": { - "water_temperature": 70.0, "intended_boiler_temperature": 70.0, "modulation_level": 1, + "water_temperature": 70.0, }, }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NAS", - "zigbee_mac_address": "ABCD012345670A14", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "4a810418d5394b3f82727340b91ba740": { - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "USG Smart Plug", - "zigbee_mac_address": "ABCD012345670A16", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "02cf28bfec924855854c544690a609ef": { - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NVR", - "zigbee_mac_address": "ABCD012345670A15", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, "a28f588dc4a049a483fd03a30361ad3a": { + "available": True, "dev_class": "settop", "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", "name": "Fibaro HC2", - "zigbee_mac_address": "ABCD012345670A13", - "vendor": "Plugwise", - "available": True, "sensors": { "electricity_consumed": 12.5, "electricity_consumed_interval": 3.8, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, }, - "switches": {"relay": True, "lock": True}, - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Lisa", - "name": "Zone Thermostat Jessie", - "zigbee_mac_address": "ABCD012345670A03", + "switches": {"lock": True, "relay": True}, "vendor": "Plugwise", - "thermostat": { - "setpoint": 15.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, - }, - "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "asleep", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "CV Jessie", - "last_used": "CV Jessie", - "mode": "auto", - "sensors": {"temperature": 17.2, "setpoint": 15.0, "battery": 37}, + "zigbee_mac_address": "ABCD012345670A13", }, - "680423ff840043738f42cc7f1ff97a36": { + "a2c3583e0a6349358998b760cea82d2a": { + "available": True, "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "location": "08963fec7c53423ca5680aa4cb502c63", + "location": "12493538af164a409c6a1c79e38afe1c", "model": "Tom/Floor", - "name": "Thermostatic Radiator Badkamer", - "zigbee_mac_address": "ABCD012345670A17", - "vendor": "Plugwise", - "available": True, + "name": "Bios Cv Thermostatic Radiator ", "sensors": { - "temperature": 19.1, - "setpoint": 14.0, - "battery": 51, - "temperature_difference": -0.4, + "battery": 62, + "setpoint": 13.0, + "temperature": 17.2, + "temperature_difference": -0.2, "valve_position": 0.0, }, - }, - "f1fee6043d3642a9b0a65297455f008e": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Lisa", - "name": "Zone Thermostat Badkamer", - "zigbee_mac_address": "ABCD012345670A08", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 14.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09", + }, + "b310b72a0e354bfab43089919b9a88bf": { + "available": True, + "dev_class": "thermo_sensor", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Tom/Floor", + "name": "Floor kraan", + "sensors": { + "setpoint": 21.5, + "temperature": 26.0, + "temperature_difference": 3.5, + "valve_position": 100, + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02", + }, + "b59bcebaf94b499ea7d46e4a66fb62d8": { + "active_preset": "home", "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "away", "available_schedules": [ "CV Roan", "Bios Schema met Film Avond", @@ -345,46 +262,76 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "select_schedule": "Badkamer Schema", - "last_used": "Badkamer Schema", + "dev_class": "zone_thermostat", + "firmware": "2016-08-02T02:00:00+02:00", + "hardware": "255", + "last_used": "GF7 Woonkamer", + "location": "c50f167537524366a5af7aa3942feb1e", "mode": "auto", - "sensors": {"temperature": 18.9, "setpoint": 14.0, "battery": 92}, + "model": "Lisa", + "name": "Zone Lisa WK", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "GF7 Woonkamer", + "sensors": {"battery": 34, "setpoint": 21.5, "temperature": 20.9}, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 21.5, + "upper_bound": 99.9, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07", }, - "675416a629f343c495449970e2ca37b5": { - "dev_class": "router", + "cd0ddb54ef694e11ac18ed1cbce5dbbd": { + "available": True, + "dev_class": "vcr", "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", - "name": "Ziggo Modem", - "zigbee_mac_address": "ABCD012345670A01", - "vendor": "Plugwise", - "available": True, + "name": "NAS", "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, + "electricity_consumed": 16.5, + "electricity_consumed_interval": 0.5, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, }, - "switches": {"relay": True, "lock": True}, + "switches": {"lock": True, "relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A14", }, - "e7693eb9582644e5b865dba8d4447cf1": { - "dev_class": "thermostatic_radiator_valve", + "d3da73bde12a47d5a6b8f9dad971f2ec": { + "available": True, + "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "location": "446ac08dd04d4eff8ac57489757b7314", + "location": "82fa13f017d240daa0d0ea1775420f24", "model": "Tom/Floor", - "name": "CV Kraan Garage", - "zigbee_mac_address": "ABCD012345670A11", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 5.5, - "lower_bound": 0.0, - "upper_bound": 100.0, - "resolution": 0.01, + "name": "Thermostatic Radiator Jessie", + "sensors": { + "battery": 62, + "setpoint": 15.0, + "temperature": 17.1, + "temperature_difference": 0.1, + "valve_position": 0.0, }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A10", + }, + "df4a4a8169904cdb9c03d61a21f42140": { + "active_preset": "away", "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "no_frost", "available_schedules": [ "CV Roan", "Bios Schema met Film Avond", @@ -392,16 +339,123 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "select_schedule": "None", + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", "last_used": "Badkamer Schema", + "location": "12493538af164a409c6a1c79e38afe1c", "mode": "heat", + "model": "Lisa", + "name": "Zone Lisa Bios", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", + "sensors": {"battery": 67, "setpoint": 13.0, "temperature": 16.5}, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A06", + }, + "e7693eb9582644e5b865dba8d4447cf1": { + "active_preset": "no_frost", + "available": True, + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + ], + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "last_used": "Badkamer Schema", + "location": "446ac08dd04d4eff8ac57489757b7314", + "mode": "heat", + "model": "Tom/Floor", + "name": "CV Kraan Garage", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", "sensors": { - "temperature": 15.6, - "setpoint": 5.5, "battery": 68, + "setpoint": 5.5, + "temperature": 15.6, "temperature_difference": 0.0, "valve_position": 0.0, }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 5.5, + "upper_bound": 100.0, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A11", + }, + "f1fee6043d3642a9b0a65297455f008e": { + "active_preset": "away", + "available": True, + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + ], + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "last_used": "Badkamer Schema", + "location": "08963fec7c53423ca5680aa4cb502c63", + "mode": "auto", + "model": "Lisa", + "name": "Zone Thermostat Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer Schema", + "sensors": {"battery": 92, "setpoint": 14.0, "temperature": 18.9}, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 14.0, + "upper_bound": 99.9, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08", + }, + "fe799307f1624099878210aa0b9f1475": { + "binary_sensors": {"plugwise_notification": True}, + "dev_class": "gateway", + "firmware": "3.0.15", + "hardware": "AME Smile 2.0 board", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "mac_address": "012345670001", + "model": "Gateway", + "name": "Adam", + "select_regulation_mode": "heating", + "sensors": {"outdoor_temperature": 7.81}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101", }, }, } diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index 9ca64e104d3..bccf257a433 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -38,7 +38,7 @@ async def test_anna_max_boiler_temp_change( assert mock_smile_anna.set_number_setpoint.call_count == 1 mock_smile_anna.set_number_setpoint.assert_called_with( - "maximum_boiler_temperature", 65.0 + "maximum_boiler_temperature", "1cbf783bb11e4a7c8a6843dee3a86927", 65.0 ) @@ -67,5 +67,5 @@ async def test_adam_dhw_setpoint_change( assert mock_smile_adam_2.set_number_setpoint.call_count == 1 mock_smile_adam_2.set_number_setpoint.assert_called_with( - "max_dhw_temperature", 55.0 + "max_dhw_temperature", "056ee145a816487eaa69243c3280f8bf", 55.0 ) From b66437ff7b4a6a3d6e4051d22d7d554f71195b3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 17:34:11 -0500 Subject: [PATCH 323/984] Bump yalexs-ble to 2.3.0 (#100007) --- 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 cd2737adca3..a2d460d12ec 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.8.0", "yalexs-ble==2.2.3"] + "requirements": ["yalexs==1.8.0", "yalexs-ble==2.3.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 3aefeea048a..cbff581d296 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.2.3"] + "requirements": ["yalexs-ble==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 021ef7989b1..5697dc28c94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2737,7 +2737,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.2.3 +yalexs-ble==2.3.0 # homeassistant.components.august yalexs==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66be97e6ae0..3416452cb9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2022,7 +2022,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.2.3 +yalexs-ble==2.3.0 # homeassistant.components.august yalexs==1.8.0 From b370244ed412444b8a3bb5a5e23f14dd055ef1da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 17:34:31 -0500 Subject: [PATCH 324/984] Switch ESPHome Bluetooth to use loop.create_future() (#100010) --- homeassistant/components/esphome/bluetooth/device.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py index 8d060151dbf..c76562a2145 100644 --- a/homeassistant/components/esphome/bluetooth/device.py +++ b/homeassistant/components/esphome/bluetooth/device.py @@ -21,6 +21,7 @@ class ESPHomeBluetoothDevice: _ble_connection_free_futures: list[asyncio.Future[int]] = field( default_factory=list ) + loop: asyncio.AbstractEventLoop = field(default_factory=asyncio.get_running_loop) @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: @@ -49,6 +50,6 @@ class ESPHomeBluetoothDevice: """Wait until there are free BLE connections.""" if self.ble_connections_free > 0: return self.ble_connections_free - fut: asyncio.Future[int] = asyncio.Future() + fut: asyncio.Future[int] = self.loop.create_future() self._ble_connection_free_futures.append(fut) return await fut From e3f228ea52f962b3a11b22f824be41d42e2f9bff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 17:34:49 -0500 Subject: [PATCH 325/984] Switch config_entries to use loop.create_future() (#100011) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f627b804989..046f403642e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -859,7 +859,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): flow_id = uuid_util.random_uuid_hex() if context["source"] == SOURCE_IMPORT: - init_done: asyncio.Future[None] = asyncio.Future() + init_done: asyncio.Future[None] = self.hass.loop.create_future() self._pending_import_flows.setdefault(handler, {})[flow_id] = init_done task = asyncio.create_task( From 4bc079b2195da93e35d593db47554f7c6e4b0457 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 00:38:17 +0200 Subject: [PATCH 326/984] Use snapshot assertion in Plugwise diagnostic test (#100008) * Use snapshot assertion in Plugwise diagnostic test * Use snapshot assertion in Plugwise diagnostic test --- .../plugwise/snapshots/test_diagnostics.ambr | 516 ++++++++++++++++++ tests/components/plugwise/test_diagnostics.py | 450 +-------------- 2 files changed, 523 insertions(+), 443 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_diagnostics.ambr diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..da6e8964421 --- /dev/null +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -0,0 +1,516 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'devices': dict({ + '02cf28bfec924855854c544690a609ef': dict({ + 'available': True, + 'dev_class': 'vcr', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'NVR', + 'sensors': dict({ + 'electricity_consumed': 34.0, + 'electricity_consumed_interval': 9.15, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A15', + }), + '21f2b542c49845e6bb416884c55778d6': dict({ + 'available': True, + 'dev_class': 'game_console', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Playstation Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 82.6, + 'electricity_consumed_interval': 8.6, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': False, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A12', + }), + '4a810418d5394b3f82727340b91ba740': dict({ + 'available': True, + 'dev_class': 'router', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'USG Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 8.5, + 'electricity_consumed_interval': 0.0, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A16', + }), + '675416a629f343c495449970e2ca37b5': dict({ + 'available': True, + 'dev_class': 'router', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Ziggo Modem', + 'sensors': dict({ + 'electricity_consumed': 12.2, + 'electricity_consumed_interval': 2.97, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A01', + }), + '680423ff840043738f42cc7f1ff97a36': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Tom/Floor', + 'name': 'Thermostatic Radiator Badkamer', + 'sensors': dict({ + 'battery': 51, + 'setpoint': 14.0, + 'temperature': 19.1, + 'temperature_difference': -0.4, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A17', + }), + '6a3bf693d05e48e0b460c815a4fdd09d': dict({ + 'active_preset': 'asleep', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'CV Jessie', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Thermostat Jessie', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'CV Jessie', + 'sensors': dict({ + 'battery': 37, + 'setpoint': 15.0, + 'temperature': 17.2, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 15.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A03', + }), + '78d1126fc4c743db81b61c20e88342a7': dict({ + 'available': True, + 'dev_class': 'central_heating_pump', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Plug', + 'name': 'CV Pomp', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_consumed_interval': 7.37, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A05', + }), + '90986d591dcd426cae3ec3e8111ff730': dict({ + 'binary_sensors': dict({ + 'heating_state': True, + }), + 'dev_class': 'heater_central', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'model': 'Unknown', + 'name': 'OnOff', + 'sensors': dict({ + 'intended_boiler_temperature': 70.0, + 'modulation_level': 1, + 'water_temperature': 70.0, + }), + }), + 'a28f588dc4a049a483fd03a30361ad3a': dict({ + 'available': True, + 'dev_class': 'settop', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Fibaro HC2', + 'sensors': dict({ + 'electricity_consumed': 12.5, + 'electricity_consumed_interval': 3.8, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A13', + }), + 'a2c3583e0a6349358998b760cea82d2a': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Tom/Floor', + 'name': 'Bios Cv Thermostatic Radiator ', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 13.0, + 'temperature': 17.2, + 'temperature_difference': -0.2, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A09', + }), + 'b310b72a0e354bfab43089919b9a88bf': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Tom/Floor', + 'name': 'Floor kraan', + 'sensors': dict({ + 'setpoint': 21.5, + 'temperature': 26.0, + 'temperature_difference': 3.5, + 'valve_position': 100, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A02', + }), + 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ + 'active_preset': 'home', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-08-02T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'GF7 Woonkamer', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Lisa WK', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'GF7 Woonkamer', + 'sensors': dict({ + 'battery': 34, + 'setpoint': 21.5, + 'temperature': 20.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 21.5, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A07', + }), + 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ + 'available': True, + 'dev_class': 'vcr', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'NAS', + 'sensors': dict({ + 'electricity_consumed': 16.5, + 'electricity_consumed_interval': 0.5, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A14', + }), + 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Tom/Floor', + 'name': 'Thermostatic Radiator Jessie', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 15.0, + 'temperature': 17.1, + 'temperature_difference': 0.1, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A10', + }), + 'df4a4a8169904cdb9c03d61a21f42140': dict({ + 'active_preset': 'away', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'Badkamer Schema', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'mode': 'heat', + 'model': 'Lisa', + 'name': 'Zone Lisa Bios', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'None', + 'sensors': dict({ + 'battery': 67, + 'setpoint': 13.0, + 'temperature': 16.5, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 13.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A06', + }), + 'e7693eb9582644e5b865dba8d4447cf1': dict({ + 'active_preset': 'no_frost', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'last_used': 'Badkamer Schema', + 'location': '446ac08dd04d4eff8ac57489757b7314', + 'mode': 'heat', + 'model': 'Tom/Floor', + 'name': 'CV Kraan Garage', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'None', + 'sensors': dict({ + 'battery': 68, + 'setpoint': 5.5, + 'temperature': 15.6, + 'temperature_difference': 0.0, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 5.5, + 'upper_bound': 100.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A11', + }), + 'f1fee6043d3642a9b0a65297455f008e': dict({ + 'active_preset': 'away', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'Badkamer Schema', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Thermostat Badkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'Badkamer Schema', + 'sensors': dict({ + 'battery': 92, + 'setpoint': 14.0, + 'temperature': 18.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 14.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A08', + }), + 'fe799307f1624099878210aa0b9f1475': dict({ + 'binary_sensors': dict({ + 'plugwise_notification': True, + }), + 'dev_class': 'gateway', + 'firmware': '3.0.15', + 'hardware': 'AME Smile 2.0 board', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'mac_address': '012345670001', + 'model': 'Gateway', + 'name': 'Adam', + 'select_regulation_mode': 'heating', + 'sensors': dict({ + 'outdoor_temperature': 7.81, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670101', + }), + }), + 'gateway': dict({ + 'cooling_present': False, + 'gateway_id': 'fe799307f1624099878210aa0b9f1475', + 'heater_id': '90986d591dcd426cae3ec3e8111ff730', + '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.", + }), + }), + 'smile_name': 'Adam', + }), + }) +# --- diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index 69f180692e2..045b8641f69 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by the Plugwise integration.""" from unittest.mock import MagicMock +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -13,449 +15,11 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, mock_smile_adam: MagicMock, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "gateway": { - "smile_name": "Adam", - "gateway_id": "fe799307f1624099878210aa0b9f1475", - "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "cooling_present": False, - "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." - } - }, - }, - "devices": { - "02cf28bfec924855854c544690a609ef": { - "available": True, - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NVR", - "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A15", - }, - "21f2b542c49845e6bb416884c55778d6": { - "available": True, - "dev_class": "game_console", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Playstation Smart Plug", - "sensors": { - "electricity_consumed": 82.6, - "electricity_consumed_interval": 8.6, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": False, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A12", - }, - "4a810418d5394b3f82727340b91ba740": { - "available": True, - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "USG Smart Plug", - "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A16", - }, - "675416a629f343c495449970e2ca37b5": { - "available": True, - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Ziggo Modem", - "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01", - }, - "680423ff840043738f42cc7f1ff97a36": { - "available": True, - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Badkamer", - "sensors": { - "battery": 51, - "setpoint": 14.0, - "temperature": 19.1, - "temperature_difference": -0.4, - "valve_position": 0.0, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A17", - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "active_preset": "asleep", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "last_used": "CV Jessie", - "location": "82fa13f017d240daa0d0ea1775420f24", - "mode": "auto", - "model": "Lisa", - "name": "Zone Thermostat Jessie", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "CV Jessie", - "sensors": {"battery": 37, "setpoint": 15.0, "temperature": 17.2}, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A03", - }, - "78d1126fc4c743db81b61c20e88342a7": { - "available": True, - "dev_class": "central_heating_pump", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Plug", - "name": "CV Pomp", - "sensors": { - "electricity_consumed": 35.6, - "electricity_consumed_interval": 7.37, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A05", - }, - "90986d591dcd426cae3ec3e8111ff730": { - "binary_sensors": {"heating_state": True}, - "dev_class": "heater_central", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "model": "Unknown", - "name": "OnOff", - "sensors": { - "intended_boiler_temperature": 70.0, - "modulation_level": 1, - "water_temperature": 70.0, - }, - }, - "a28f588dc4a049a483fd03a30361ad3a": { - "available": True, - "dev_class": "settop", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Fibaro HC2", - "sensors": { - "electricity_consumed": 12.5, - "electricity_consumed_interval": 3.8, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A13", - }, - "a2c3583e0a6349358998b760cea82d2a": { - "available": True, - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Tom/Floor", - "name": "Bios Cv Thermostatic Radiator ", - "sensors": { - "battery": 62, - "setpoint": 13.0, - "temperature": 17.2, - "temperature_difference": -0.2, - "valve_position": 0.0, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A09", - }, - "b310b72a0e354bfab43089919b9a88bf": { - "available": True, - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Tom/Floor", - "name": "Floor kraan", - "sensors": { - "setpoint": 21.5, - "temperature": 26.0, - "temperature_difference": 3.5, - "valve_position": 100, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02", - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "active_preset": "home", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "zone_thermostat", - "firmware": "2016-08-02T02:00:00+02:00", - "hardware": "255", - "last_used": "GF7 Woonkamer", - "location": "c50f167537524366a5af7aa3942feb1e", - "mode": "auto", - "model": "Lisa", - "name": "Zone Lisa WK", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "GF7 Woonkamer", - "sensors": {"battery": 34, "setpoint": 21.5, "temperature": 20.9}, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 21.5, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07", - }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "available": True, - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NAS", - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A14", - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "available": True, - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Jessie", - "sensors": { - "battery": 62, - "setpoint": 15.0, - "temperature": 17.1, - "temperature_difference": 0.1, - "valve_position": 0.0, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A10", - }, - "df4a4a8169904cdb9c03d61a21f42140": { - "active_preset": "away", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "last_used": "Badkamer Schema", - "location": "12493538af164a409c6a1c79e38afe1c", - "mode": "heat", - "model": "Lisa", - "name": "Zone Lisa Bios", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", - "sensors": {"battery": 67, "setpoint": 13.0, "temperature": 16.5}, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A06", - }, - "e7693eb9582644e5b865dba8d4447cf1": { - "active_preset": "no_frost", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "last_used": "Badkamer Schema", - "location": "446ac08dd04d4eff8ac57489757b7314", - "mode": "heat", - "model": "Tom/Floor", - "name": "CV Kraan Garage", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", - "sensors": { - "battery": 68, - "setpoint": 5.5, - "temperature": 15.6, - "temperature_difference": 0.0, - "valve_position": 0.0, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 5.5, - "upper_bound": 100.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A11", - }, - "f1fee6043d3642a9b0a65297455f008e": { - "active_preset": "away", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "last_used": "Badkamer Schema", - "location": "08963fec7c53423ca5680aa4cb502c63", - "mode": "auto", - "model": "Lisa", - "name": "Zone Thermostat Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "Badkamer Schema", - "sensors": {"battery": 92, "setpoint": 14.0, "temperature": 18.9}, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 14.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A08", - }, - "fe799307f1624099878210aa0b9f1475": { - "binary_sensors": {"plugwise_notification": True}, - "dev_class": "gateway", - "firmware": "3.0.15", - "hardware": "AME Smile 2.0 board", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "mac_address": "012345670001", - "model": "Gateway", - "name": "Adam", - "select_regulation_mode": "heating", - "sensors": {"outdoor_temperature": 7.81}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101", - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 6c613fd2558f91a8f28e38f4894102f64f9511bf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 00:38:43 +0200 Subject: [PATCH 327/984] Move static attributes outside of ws66i constructor (#99922) Move static attributes outside of ws66i cosntructor --- .../components/ws66i/media_player.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index b5c87fbc0f3..7119002cbc4 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -46,6 +46,14 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity _attr_has_entity_name = True _attr_name = None + _attr_supported_features = ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE + ) def __init__( self, @@ -64,18 +72,10 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity self._zone_id_idx: int = data_idx self._status: ZoneStatus = coordinator.data[data_idx] self._attr_source_list = ws66i_data.sources.name_list - self._attr_unique_id = f"{entry_id}_{self._zone_id}" - self._attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_STEP - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF - | MediaPlayerEntityFeature.SELECT_SOURCE - ) + self._attr_unique_id = f"{entry_id}_{zone_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(self.unique_id))}, - name=f"Zone {self._zone_id}", + name=f"Zone {zone_id}", manufacturer="Soundavo", model="WS66i 6-Zone Amplifier", ) From 8de3945bd46f3c46c2f5ea6645ca01ac910ba0b6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 00:38:57 +0200 Subject: [PATCH 328/984] Fix renamed code owner for Versasense (#99976) Fix renamed code owner --- CODEOWNERS | 2 +- homeassistant/components/versasense/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b4eb1e39072..6f7a0099494 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1365,7 +1365,7 @@ build.json @home-assistant/supervisor /tests/components/venstar/ @garbled1 /homeassistant/components/verisure/ @frenck @niro1987 /tests/components/verisure/ @frenck @niro1987 -/homeassistant/components/versasense/ @flamm3blemuff1n +/homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey diff --git a/homeassistant/components/versasense/manifest.json b/homeassistant/components/versasense/manifest.json index 0dd63218939..421a46bc2f6 100644 --- a/homeassistant/components/versasense/manifest.json +++ b/homeassistant/components/versasense/manifest.json @@ -1,7 +1,7 @@ { "domain": "versasense", "name": "VersaSense", - "codeowners": ["@flamm3blemuff1n"], + "codeowners": ["@imstevenxyz"], "documentation": "https://www.home-assistant.io/integrations/versasense", "iot_class": "local_polling", "loggers": ["pyversasense"], From 092580a3ed23f51565bd11aed3d2421d24381b2f Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sat, 9 Sep 2023 15:39:54 -0700 Subject: [PATCH 329/984] Bump screenlogicpy to v0.9.0 (#92475) Co-authored-by: J. Nick Koston --- .coveragerc | 3 +- .../components/screenlogic/__init__.py | 174 ++-- .../components/screenlogic/binary_sensor.py | 265 ++--- .../components/screenlogic/climate.py | 131 +-- .../components/screenlogic/config_flow.py | 21 +- homeassistant/components/screenlogic/const.py | 47 +- .../components/screenlogic/coordinator.py | 97 ++ homeassistant/components/screenlogic/data.py | 304 ++++++ .../components/screenlogic/diagnostics.py | 2 +- .../components/screenlogic/entity.py | 122 +-- homeassistant/components/screenlogic/light.py | 58 +- .../components/screenlogic/manifest.json | 2 +- .../components/screenlogic/number.py | 208 +++- .../components/screenlogic/sensor.py | 427 ++++---- .../components/screenlogic/switch.py | 61 +- homeassistant/components/screenlogic/util.py | 40 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/screenlogic/__init__.py | 66 ++ tests/components/screenlogic/conftest.py | 27 + .../screenlogic/fixtures/data_full_chem.json | 880 ++++++++++++++++ .../fixtures/data_min_entity_cleanup.json | 38 + .../fixtures/data_min_migration.json | 151 +++ .../snapshots/test_diagnostics.ambr | 960 ++++++++++++++++++ .../screenlogic/test_config_flow.py | 2 +- tests/components/screenlogic/test_data.py | 91 ++ .../screenlogic/test_diagnostics.py | 56 + tests/components/screenlogic/test_init.py | 236 +++++ 28 files changed, 3821 insertions(+), 652 deletions(-) create mode 100644 homeassistant/components/screenlogic/coordinator.py create mode 100644 homeassistant/components/screenlogic/data.py create mode 100644 homeassistant/components/screenlogic/util.py create mode 100644 tests/components/screenlogic/conftest.py create mode 100644 tests/components/screenlogic/fixtures/data_full_chem.json create mode 100644 tests/components/screenlogic/fixtures/data_min_entity_cleanup.json create mode 100644 tests/components/screenlogic/fixtures/data_min_migration.json create mode 100644 tests/components/screenlogic/snapshots/test_diagnostics.ambr create mode 100644 tests/components/screenlogic/test_data.py create mode 100644 tests/components/screenlogic/test_diagnostics.py create mode 100644 tests/components/screenlogic/test_init.py diff --git a/.coveragerc b/.coveragerc index d9cb511e86e..ecc835106ff 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1071,9 +1071,10 @@ omit = homeassistant/components/saj/sensor.py homeassistant/components/satel_integra/* homeassistant/components/schluter/* - homeassistant/components/screenlogic/__init__.py homeassistant/components/screenlogic/binary_sensor.py homeassistant/components/screenlogic/climate.py + homeassistant/components/screenlogic/coordinator.py + homeassistant/components/screenlogic/const.py homeassistant/components/screenlogic/entity.py homeassistant/components/screenlogic/light.py homeassistant/components/screenlogic/number.py diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 3370c196c3c..298e1c1ca00 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -1,27 +1,22 @@ """The Screenlogic integration.""" -from datetime import timedelta import logging from typing import Any from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const import ( - DATA as SL_DATA, - EQUIPMENT, - SL_GATEWAY_IP, - SL_GATEWAY_NAME, - SL_GATEWAY_PORT, -) +from screenlogicpy.const.data import SHARED_VALUES from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify -from .config_flow import async_discover_gateways_by_unique_id, name_for_mac -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info +from .data import ENTITY_MIGRATIONS from .services import async_load_screenlogic_services, async_unload_screenlogic_services +from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -44,12 +39,16 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" + + await _async_migrate_entries(hass, entry) + gateway = ScreenLogicGateway() connect_info = await async_get_connect_info(hass, entry) try: await gateway.async_connect(**connect_info) + await gateway.async_update() except ScreenLogicError as ex: raise ConfigEntryNotReady(ex.msg) from ex @@ -88,83 +87,88 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None await hass.config_entries.async_reload(entry.entry_id) -async def async_get_connect_info( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, str | int]: - """Construct connect_info from configuration entry and returns it to caller.""" - mac = entry.unique_id - # Attempt to rediscover gateway to follow IP changes - discovered_gateways = await async_discover_gateways_by_unique_id(hass) - if mac in discovered_gateways: - return discovered_gateways[mac] +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate to new entity names.""" + entity_registry = er.async_get(hass) - _LOGGER.warning("Gateway rediscovery failed") - # Static connection defined or fallback from discovery - return { - SL_GATEWAY_NAME: name_for_mac(mac), - SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], - SL_GATEWAY_PORT: entry.data[CONF_PORT], - } + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ): + source_mac, source_key = entry.unique_id.split("_", 1) + source_index = None + if ( + len(key_parts := source_key.rsplit("_", 1)) == 2 + and key_parts[1].isdecimal() + ): + source_key, source_index = key_parts -class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage the data update for the Screenlogic component.""" - - def __init__( - self, - hass: HomeAssistant, - *, - config_entry: ConfigEntry, - gateway: ScreenLogicGateway, - ) -> None: - """Initialize the Screenlogic Data Update Coordinator.""" - self.config_entry = config_entry - self.gateway = gateway - - interval = timedelta( - seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=interval, - # Debounced option since the device takes - # a moment to reflect the knock-on changes - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False - ), + _LOGGER.debug( + "Checking migration status for '%s' against key '%s'", + entry.unique_id, + source_key, ) - @property - def gateway_data(self) -> dict[str | int, Any]: - """Return the gateway data.""" - return self.gateway.get_data() + if source_key not in ENTITY_MIGRATIONS: + continue - async def _async_update_configured_data(self) -> None: - """Update data sets based on equipment config.""" - equipment_flags = self.gateway.get_data()[SL_DATA.KEY_CONFIG]["equipment_flags"] - if not self.gateway.is_client: - await self.gateway.async_get_status() - if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: - await self.gateway.async_get_chemistry() - - await self.gateway.async_get_pumps() - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - await self.gateway.async_get_scg() - - async def _async_update_data(self) -> None: - """Fetch data from the Screenlogic gateway.""" - assert self.config_entry is not None - try: - if not self.gateway.is_connected: - connect_info = await async_get_connect_info( - self.hass, self.config_entry + _LOGGER.debug( + "Evaluating migration of '%s' from migration key '%s'", + entry.entity_id, + source_key, + ) + migrations = ENTITY_MIGRATIONS[source_key] + updates: dict[str, Any] = {} + new_key = migrations["new_key"] + if new_key in SHARED_VALUES: + if (device := migrations.get("device")) is None: + _LOGGER.debug( + "Shared key '%s' is missing required migration data 'device'", + new_key, ) - await self.gateway.async_connect(**connect_info) + continue + assert device is not None and ( + device != "pump" or (device == "pump" and source_index is not None) + ) + new_unique_id = ( + f"{source_mac}_{generate_unique_id(device, source_index, new_key)}" + ) + else: + new_unique_id = entry.unique_id.replace(source_key, new_key) - await self._async_update_configured_data() - except ScreenLogicError as ex: - if self.gateway.is_connected: - await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + if new_unique_id and new_unique_id != entry.unique_id: + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + _LOGGER.debug( + "Cannot migrate '%s' to unique_id '%s', already exists for entity '%s'. Aborting", + entry.unique_id, + new_unique_id, + existing_entity_id, + ) + continue + updates["new_unique_id"] = new_unique_id + + if (old_name := migrations.get("old_name")) is not None: + assert old_name + new_name = migrations["new_name"] + if (s_old_name := slugify(old_name)) in entry.entity_id: + new_entity_id = entry.entity_id.replace(s_old_name, slugify(new_name)) + if new_entity_id and new_entity_id != entry.entity_id: + updates["new_entity_id"] = new_entity_id + + if entry.original_name and old_name in entry.original_name: + new_original_name = entry.original_name.replace(old_name, new_name) + if new_original_name and new_original_name != entry.original_name: + updates["original_name"] = new_original_name + + if updates: + _LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + entity_registry.async_update_entity(entry.entity_id, **updates) diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 30577584494..337d308d8d9 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,28 +1,97 @@ """Support for a ScreenLogic Binary Sensor.""" -from screenlogicpy.const import CODE, DATA as SL_DATA, DEVICE_TYPE, EQUIPMENT, ON_OFF +from dataclasses import dataclass +import logging + +from screenlogicpy.const.common import DEVICE_TYPE, ON_OFF +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from homeassistant.components.binary_sensor import ( + DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity, ScreenLogicPushEntity +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + DEVICE_SUBSCRIPTION, + SupportedValueParameters, + build_base_entity_description, + iterate_expand_group_wildcard, + preprocess_supported_values, +) +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, +) +from .util import cleanup_excluded_entity, generate_unique_id + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SupportedBinarySensorValueParameters(SupportedValueParameters): + """Supported predefined data for a ScreenLogic binary sensor entity.""" + + device_class: BinarySensorDeviceClass | None = None + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.CONTROLLER: { + GROUP.SENSOR: { + VALUE.ACTIVE_ALERT: SupportedBinarySensorValueParameters(), + VALUE.CLEANER_DELAY: SupportedBinarySensorValueParameters(), + VALUE.FREEZE_MODE: SupportedBinarySensorValueParameters(), + VALUE.POOL_DELAY: SupportedBinarySensorValueParameters(), + VALUE.SPA_DELAY: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.PUMP: { + "*": { + VALUE.STATE: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.INTELLICHEM: { + GROUP.ALARM: { + VALUE.FLOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_HIGH_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_LOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_HIGH_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_LOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PROBE_FAULT_ALARM: SupportedBinarySensorValueParameters(), + }, + GROUP.ALERT: { + VALUE.ORP_LIMIT: SupportedBinarySensorValueParameters(), + VALUE.PH_LIMIT: SupportedBinarySensorValueParameters(), + VALUE.PH_LOCKOUT: SupportedBinarySensorValueParameters(), + }, + GROUP.WATER_BALANCE: { + VALUE.CORROSIVE: SupportedBinarySensorValueParameters(), + VALUE.SCALING: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.SCG: { + GROUP.SENSOR: { + VALUE.STATE: SupportedBinarySensorValueParameters(), + }, + }, + } +) SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {DEVICE_TYPE.ALARM: BinarySensorDeviceClass.PROBLEM} -SUPPORTED_CONFIG_BINARY_SENSORS = ( - "freeze_mode", - "pool_delay", - "spa_delay", - "cleaner_delay", -) - async def async_setup_entry( hass: HomeAssistant, @@ -30,132 +99,92 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicBinarySensorEntity] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicBinarySensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - gateway_data = coordinator.gateway_data - config = gateway_data[SL_DATA.KEY_CONFIG] + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedBinarySensorValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) - # Generic binary sensor - entities.append( - ScreenLogicStatusBinarySensor(coordinator, "chem_alarm", CODE.STATUS_CHANGED) - ) + device = data_path[0] - entities.extend( - [ - ScreenlogicConfigBinarySensor(coordinator, cfg_sensor, CODE.STATUS_CHANGED) - for cfg_sensor in config - if cfg_sensor in SUPPORTED_CONFIG_BINARY_SENSORS - ] - ) + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue - if config["equipment_flags"] & EQUIPMENT.FLAG_INTELLICHEM: - chemistry = gateway_data[SL_DATA.KEY_CHEMISTRY] - # IntelliChem alarm sensors - entities.extend( - [ - ScreenlogicChemistryAlarmBinarySensor( - coordinator, chem_alarm, CODE.CHEMISTRY_CHANGED + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( + value_data.get(ATTR.DEVICE_TYPE) + ), + } + + if ( + sub_code := ( + value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + ) + ) is not None: + entities.append( + ScreenLogicPushBinarySensor( + coordinator, + ScreenLogicPushBinarySensorDescription( + subscription_code=sub_code, **entity_description_kwargs + ), ) - for chem_alarm in chemistry[SL_DATA.KEY_ALERTS] - if not chem_alarm.startswith("_") - ] - ) - - # Intellichem notification sensors - entities.extend( - [ - ScreenlogicChemistryNotificationBinarySensor( - coordinator, chem_notif, CODE.CHEMISTRY_CHANGED + ) + else: + entities.append( + ScreenLogicBinarySensor( + coordinator, + ScreenLogicBinarySensorDescription(**entity_description_kwargs), ) - for chem_notif in chemistry[SL_DATA.KEY_NOTIFICATIONS] - if not chem_notif.startswith("_") - ] - ) - - if config["equipment_flags"] & EQUIPMENT.FLAG_CHLORINATOR: - # SCG binary sensor - entities.append(ScreenlogicSCGBinarySensor(coordinator, "scg_status")) + ) async_add_entities(entities) -class ScreenLogicBinarySensorEntity(ScreenlogicEntity, BinarySensorEntity): +@dataclass +class ScreenLogicBinarySensorDescription( + BinarySensorEntityDescription, ScreenLogicEntityDescription +): + """A class that describes ScreenLogic binary sensor eneites.""" + + +class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): """Base class for all ScreenLogic binary sensor entities.""" + entity_description: ScreenLogicBinarySensorDescription _attr_has_entity_name = True - _attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def name(self) -> str | None: - """Return the sensor name.""" - return self.sensor["name"] - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the device class.""" - device_type = self.sensor.get("device_type") - return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) @property def is_on(self) -> bool: """Determine if the sensor is on.""" - return self.sensor["value"] == ON_OFF.ON - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key] + return self.entity_data[ATTR.VALUE] == ON_OFF.ON -class ScreenLogicStatusBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity +@dataclass +class ScreenLogicPushBinarySensorDescription( + ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription ): + """Describes a ScreenLogicPushBinarySensor.""" + + +class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor): """Representation of a basic ScreenLogic sensor entity.""" - -class ScreenlogicChemistryAlarmBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic IntelliChem alarm binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][SL_DATA.KEY_ALERTS][ - self._data_key - ] - - -class ScreenlogicChemistryNotificationBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic IntelliChem notification binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][SL_DATA.KEY_NOTIFICATIONS][ - self._data_key - ] - - -class ScreenlogicSCGBinarySensor(ScreenLogicBinarySensorEntity): - """Representation of a ScreenLogic SCG binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] - - -class ScreenlogicConfigBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic config data binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG][self._data_key] + entity_description: ScreenLogicPushBinarySensorDescription diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index cea546262ae..889c8617274 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -1,12 +1,18 @@ """Support for a ScreenLogic heating device.""" +from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, HEAT_MODE +from screenlogicpy.const.common import UNIT +from screenlogicpy.const.data import ATTR, DEVICE, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.heat import HEAT_MODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.climate import ( ATTR_PRESET_MODE, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, @@ -18,9 +24,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenLogicPushEntity +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicPushEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -41,81 +47,88 @@ async def async_setup_entry( ) -> None: """Set up entry.""" entities = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - for body in coordinator.gateway_data[SL_DATA.KEY_BODIES]: - entities.append(ScreenLogicClimate(coordinator, body)) + gateway = coordinator.gateway + + for body_index, body_data in gateway.get_data(DEVICE.BODY).items(): + body_path = (DEVICE.BODY, body_index) + entities.append( + ScreenLogicClimate( + coordinator, + ScreenLogicClimateDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=body_path, + key=body_index, + name=body_data[VALUE.HEAT_STATE][ATTR.NAME], + ), + ) + ) async_add_entities(entities) +@dataclass +class ScreenLogicClimateDescription( + ClimateEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic climate entity.""" + + class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): """Represents a ScreenLogic climate entity.""" - _attr_has_entity_name = True - + entity_description: ScreenLogicClimateDescription _attr_hvac_modes = SUPPORTED_MODES _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) - def __init__(self, coordinator, body): + def __init__(self, coordinator, entity_description) -> None: """Initialize a ScreenLogic climate entity.""" - super().__init__(coordinator, body, CODE.STATUS_CHANGED) + super().__init__(coordinator, entity_description) self._configured_heat_modes = [] # Is solar listed as available equipment? - if self.gateway_data["config"]["equipment_flags"] & EQUIPMENT.FLAG_SOLAR: + if EQUIPMENT_FLAG.SOLAR in self.gateway.equipment_flags: self._configured_heat_modes.extend( [HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERRED] ) self._configured_heat_modes.append(HEAT_MODE.HEATER) + + self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] + self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] self._last_preset = None - @property - def name(self) -> str: - """Name of the heater.""" - return self.body["heat_status"]["name"] - - @property - def min_temp(self) -> float: - """Minimum allowed temperature.""" - return self.body["min_set_point"]["value"] - - @property - def max_temp(self) -> float: - """Maximum allowed temperature.""" - return self.body["max_set_point"]["value"] - @property def current_temperature(self) -> float: """Return water temperature.""" - return self.body["last_temperature"]["value"] + return self.entity_data[VALUE.LAST_TEMPERATURE][ATTR.VALUE] @property def target_temperature(self) -> float: """Target temperature.""" - return self.body["heat_set_point"]["value"] + return self.entity_data[VALUE.HEAT_SETPOINT][ATTR.VALUE] @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - if self.config_data["is_celsius"]["value"] == 1: + if self.gateway.temperature_unit == UNIT.CELSIUS: return UnitOfTemperature.CELSIUS return UnitOfTemperature.FAHRENHEIT @property def hvac_mode(self) -> HVACMode: """Return the current hvac mode.""" - if self.body["heat_mode"]["value"] > 0: + if self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE] > 0: return HVACMode.HEAT return HVACMode.OFF @property def hvac_action(self) -> HVACAction: """Return the current action of the heater.""" - if self.body["heat_status"]["value"] > 0: + if self.entity_data[VALUE.HEAT_STATE][ATTR.VALUE] > 0: return HVACAction.HEATING if self.hvac_mode == HVACMode.HEAT: return HVACAction.IDLE @@ -125,15 +138,13 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): def preset_mode(self) -> str: """Return current/last preset mode.""" if self.hvac_mode == HVACMode.OFF: - return HEAT_MODE.NAME_FOR_NUM[self._last_preset] - return HEAT_MODE.NAME_FOR_NUM[self.body["heat_mode"]["value"]] + return HEAT_MODE(self._last_preset).title + return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title @property def preset_modes(self) -> list[str]: """All available presets.""" - return [ - HEAT_MODE.NAME_FOR_NUM[mode_num] for mode_num in self._configured_heat_modes - ] + return [HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes] async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the heater.""" @@ -145,7 +156,7 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): ): raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.body['body_type']['value']}" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) @@ -154,28 +165,33 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if hvac_mode == HVACMode.OFF: mode = HEAT_MODE.OFF else: - mode = HEAT_MODE.NUM_FOR_NAME[self.preset_mode] + mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + if not await self.gateway.async_set_heat_mode( + int(self._data_key), int(mode.value) + ): raise HomeAssistantError( - f"Failed to set_hvac_mode {mode} on body" - f" {self.body['body_type']['value']}" + f"Failed to set_hvac_mode {mode.name} on body" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) - _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode) + _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: """Set the preset mode.""" - _LOGGER.debug("Setting last_preset to %s", HEAT_MODE.NUM_FOR_NAME[preset_mode]) - self._last_preset = mode = HEAT_MODE.NUM_FOR_NAME[preset_mode] + mode = HEAT_MODE.parse(preset_mode) + _LOGGER.debug("Setting last_preset to %s", mode.name) + self._last_preset = mode.value if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + if not await self.gateway.async_set_heat_mode( + int(self._data_key), int(mode.value) + ): raise HomeAssistantError( - f"Failed to set_preset_mode {mode} on body" - f" {self.body['body_type']['value']}" + f"Failed to set_preset_mode {mode.name} on body" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) - _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode) + _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: """Run when entity is about to be added.""" @@ -189,21 +205,16 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): prev_state is not None and prev_state.attributes.get(ATTR_PRESET_MODE) is not None ): + mode = HEAT_MODE.parse(prev_state.attributes.get(ATTR_PRESET_MODE)) _LOGGER.debug( "Startup setting last_preset to %s from prev_state", - HEAT_MODE.NUM_FOR_NAME[prev_state.attributes.get(ATTR_PRESET_MODE)], + mode.name, ) - self._last_preset = HEAT_MODE.NUM_FOR_NAME[ - prev_state.attributes.get(ATTR_PRESET_MODE) - ] + self._last_preset = mode.value else: + mode = HEAT_MODE.parse(self._configured_heat_modes[0]) _LOGGER.debug( "Startup setting last_preset to default (%s)", - self._configured_heat_modes[0], + mode.name, ) - self._last_preset = self._configured_heat_modes[0] - - @property - def body(self): - """Shortcut to access body data.""" - return self.gateway_data[SL_DATA.KEY_BODIES][self._data_key] + self._last_preset = mode.value diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 77040bdb216..25d00e3a2ce 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -2,9 +2,10 @@ from __future__ import annotations import logging +from typing import Any from screenlogicpy import ScreenLogicError, discovery -from screenlogicpy.const import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT from screenlogicpy.requests import login import voluptuous as vol @@ -64,10 +65,10 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize ScreenLogic ConfigFlow.""" - self.discovered_gateways = {} - self.discovered_ip = None + self.discovered_gateways: dict[str, dict[str, Any]] = {} + self.discovered_ip: str | None = None @staticmethod @callback @@ -77,7 +78,7 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for ScreenLogic.""" return ScreenLogicOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle the start of the config flow.""" self.discovered_gateways = await async_discover_gateways_by_unique_id(self.hass) return await self.async_step_gateway_select() @@ -93,7 +94,7 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"name": discovery_info.hostname} return await self.async_step_gateway_entry() - async def async_step_gateway_select(self, user_input=None): + async def async_step_gateway_select(self, user_input=None) -> FlowResult: """Handle the selection of a discovered ScreenLogic gateway.""" existing = self._async_current_ids() unconfigured_gateways = { @@ -105,7 +106,7 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not unconfigured_gateways: return await self.async_step_gateway_entry() - errors = {} + errors: dict[str, str] = {} if user_input is not None: if user_input[GATEWAY_SELECT_KEY] == GATEWAY_MANUAL_ENTRY: return await self.async_step_gateway_entry() @@ -140,9 +141,9 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={}, ) - async def async_step_gateway_entry(self, user_input=None): + async def async_step_gateway_entry(self, user_input=None) -> FlowResult: """Handle the manual entry of a ScreenLogic gateway.""" - errors = {} + errors: dict[str, str] = {} ip_address = self.discovered_ip port = 80 @@ -186,7 +187,7 @@ class ScreenLogicOptionsFlowHandler(config_entries.OptionsFlow): """Init the screen logic options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index e4a5ea82186..8181e0f612a 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -1,25 +1,48 @@ """Constants for the ScreenLogic integration.""" -from screenlogicpy.const import CIRCUIT_FUNCTION, COLOR_MODE +from screenlogicpy.const.common import UNIT +from screenlogicpy.device_const.circuit import FUNCTION +from screenlogicpy.device_const.system import COLOR_MODE +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfElectricPotential, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.util import slugify +ScreenLogicDataPath = tuple[str | int, ...] + DOMAIN = "screenlogic" DEFAULT_SCAN_INTERVAL = 30 MIN_SCAN_INTERVAL = 10 SERVICE_SET_COLOR_MODE = "set_color_mode" ATTR_COLOR_MODE = "color_mode" -SUPPORTED_COLOR_MODES = { - slugify(name): num for num, name in COLOR_MODE.NAME_FOR_NUM.items() -} +SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE} LIGHT_CIRCUIT_FUNCTIONS = { - CIRCUIT_FUNCTION.COLOR_WHEEL, - CIRCUIT_FUNCTION.DIMMER, - CIRCUIT_FUNCTION.INTELLIBRITE, - CIRCUIT_FUNCTION.LIGHT, - CIRCUIT_FUNCTION.MAGICSTREAM, - CIRCUIT_FUNCTION.PHOTONGEN, - CIRCUIT_FUNCTION.SAL_LIGHT, - CIRCUIT_FUNCTION.SAM_LIGHT, + FUNCTION.COLOR_WHEEL, + FUNCTION.DIMMER, + FUNCTION.INTELLIBRITE, + FUNCTION.LIGHT, + FUNCTION.MAGICSTREAM, + FUNCTION.PHOTONGEN, + FUNCTION.SAL_LIGHT, + FUNCTION.SAM_LIGHT, +} + +SL_UNIT_TO_HA_UNIT = { + UNIT.CELSIUS: UnitOfTemperature.CELSIUS, + UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT, + UNIT.WATT: UnitOfPower.WATT, + UNIT.HOUR: UnitOfTime.HOURS, + UNIT.SECOND: UnitOfTime.SECONDS, + UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE, + UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, + UNIT.PERCENT: PERCENTAGE, } diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py new file mode 100644 index 00000000000..74f49927171 --- /dev/null +++ b/homeassistant/components/screenlogic/coordinator.py @@ -0,0 +1,97 @@ +"""ScreenlogicDataUpdateCoordinator definition.""" +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.device_const.system import EQUIPMENT_FLAG + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .config_flow import async_discover_gateways_by_unique_id, name_for_mac +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +REQUEST_REFRESH_DELAY = 2 +HEATER_COOLDOWN_DELAY = 6 + + +async def async_get_connect_info( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, str | int]: + """Construct connect_info from configuration entry and returns it to caller.""" + mac = entry.unique_id + # Attempt to rediscover gateway to follow IP changes + discovered_gateways = await async_discover_gateways_by_unique_id(hass) + if mac in discovered_gateways: + return discovered_gateways[mac] + + _LOGGER.debug("Gateway rediscovery failed for %s", entry.title) + # Static connection defined or fallback from discovery + return { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } + + +class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage the data update for the Screenlogic component.""" + + def __init__( + self, + hass: HomeAssistant, + *, + config_entry: ConfigEntry, + gateway: ScreenLogicGateway, + ) -> None: + """Initialize the Screenlogic Data Update Coordinator.""" + self.config_entry = config_entry + self.gateway = gateway + + interval = timedelta( + seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + # Debounced option since the device takes + # a moment to reflect the knock-on changes + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + + async def _async_update_configured_data(self) -> None: + """Update data sets based on equipment config.""" + if not self.gateway.is_client: + await self.gateway.async_get_status() + if EQUIPMENT_FLAG.INTELLICHEM in self.gateway.equipment_flags: + await self.gateway.async_get_chemistry() + + await self.gateway.async_get_pumps() + if EQUIPMENT_FLAG.CHLORINATOR in self.gateway.equipment_flags: + await self.gateway.async_get_scg() + + async def _async_update_data(self) -> None: + """Fetch data from the Screenlogic gateway.""" + assert self.config_entry is not None + try: + if not self.gateway.is_connected: + connect_info = await async_get_connect_info( + self.hass, self.config_entry + ) + await self.gateway.async_connect(**connect_info) + + await self._async_update_configured_data() + except ScreenLogicError as ex: + if self.gateway.is_connected: + await self.gateway.async_disconnect() + raise UpdateFailed(ex.msg) from ex diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py new file mode 100644 index 00000000000..5679b7e4dc9 --- /dev/null +++ b/homeassistant/components/screenlogic/data.py @@ -0,0 +1,304 @@ +"""Support for configurable supported data values for the ScreenLogic integration.""" +from collections.abc import Callable, Generator +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.data import ATTR, DEVICE, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG + +from homeassistant.const import EntityCategory + +from .const import SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath + + +class PathPart(StrEnum): + """Placeholders for local data_path values.""" + + DEVICE = "!device" + KEY = "!key" + INDEX = "!index" + VALUE = "!sensor" + + +ScreenLogicDataPathTemplate = tuple[PathPart | str | int, ...] + + +class ScreenLogicRule: + """Represents a base default passing rule.""" + + def __init__( + self, test: Callable[..., bool] = lambda gateway, data_path: True + ) -> None: + """Initialize a ScreenLogic rule.""" + self._test = test + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Method to check the rule.""" + return self._test(gateway, data_path) + + +class ScreenLogicDataRule(ScreenLogicRule): + """Represents a data rule.""" + + def __init__( + self, test: Callable[..., bool], test_path_template: tuple[PathPart, ...] + ) -> None: + """Initialize a ScreenLogic data rule.""" + self._test_path_template = test_path_template + super().__init__(test) + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Check the rule against the gateway's data.""" + test_path = realize_path_template(self._test_path_template, data_path) + return self._test(gateway.get_data(*test_path)) + + +class ScreenLogicEquipmentRule(ScreenLogicRule): + """Represents an equipment flag rule.""" + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Check the rule against the gateway's equipment flags.""" + return self._test(gateway.equipment_flags) + + +@dataclass +class SupportedValueParameters: + """Base supported values for ScreenLogic Entities.""" + + enabled: ScreenLogicRule = ScreenLogicRule() + included: ScreenLogicRule = ScreenLogicRule() + subscription_code: int | None = None + entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC + + +SupportedValueDescriptions = dict[str, SupportedValueParameters] + +SupportedGroupDescriptions = dict[int | str, SupportedValueDescriptions] + +SupportedDeviceDescriptions = dict[str, SupportedGroupDescriptions] + + +DEVICE_INCLUSION_RULES = { + DEVICE.PUMP: ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.DATA] != 0, + (PathPart.DEVICE, PathPart.INDEX), + ), + DEVICE.INTELLICHEM: ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags, + ), + DEVICE.SCG: ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.CHLORINATOR in flags, + ), +} + +DEVICE_SUBSCRIPTION = { + DEVICE.CONTROLLER: CODE.STATUS_CHANGED, + DEVICE.INTELLICHEM: CODE.CHEMISTRY_CHANGED, +} + + +# not run-time +def get_ha_unit(entity_data: dict) -> StrEnum | str | None: + """Return a Home Assistant unit of measurement from a UNIT.""" + sl_unit = entity_data.get(ATTR.UNIT) + return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) + + +# partial run-time +def realize_path_template( + template_path: ScreenLogicDataPathTemplate, data_path: ScreenLogicDataPath +) -> ScreenLogicDataPath: + """Create a new data path using a template and an existing data path. + + Construct new ScreenLogicDataPath from data_path using + template_path to specify values from data_path. + """ + if not data_path or len(data_path) < 3: + raise KeyError( + f"Missing or invalid required parameter: 'data_path' for template path '{template_path}'" + ) + device, group, data_key = data_path + realized_path: list[str | int] = [] + for part in template_path: + match part: + case PathPart.DEVICE: + realized_path.append(device) + case PathPart.INDEX | PathPart.KEY: + realized_path.append(group) + case PathPart.VALUE: + realized_path.append(data_key) + case _: + realized_path.append(part) + + return tuple(realized_path) + + +def preprocess_supported_values( + supported_devices: SupportedDeviceDescriptions, +) -> list[tuple[ScreenLogicDataPath, Any]]: + """Expand config dict into list of ScreenLogicDataPaths and settings.""" + processed: list[tuple[ScreenLogicDataPath, Any]] = [] + for device, device_groups in supported_devices.items(): + for group, group_values in device_groups.items(): + for value_key, value_params in group_values.items(): + value_data_path = (device, group, value_key) + processed.append((value_data_path, value_params)) + return processed + + +def iterate_expand_group_wildcard( + gateway: ScreenLogicGateway, + preprocessed_data: list[tuple[ScreenLogicDataPath, Any]], +) -> Generator[tuple[ScreenLogicDataPath, Any], None, None]: + """Iterate and expand any group wildcards to all available entries in gateway.""" + for data_path, value_params in preprocessed_data: + device, group, value_key = data_path + if group == "*": + for index in gateway.get_data(device): + yield ((device, index, value_key), value_params) + else: + yield (data_path, value_params) + + +def build_base_entity_description( + gateway: ScreenLogicGateway, + entity_key: str, + data_path: ScreenLogicDataPath, + value_data: dict, + value_params: SupportedValueParameters, +) -> dict: + """Build base entity description. + + Returns a dict of entity description key value pairs common to all entities. + """ + return { + "data_path": data_path, + "key": entity_key, + "entity_category": value_params.entity_category, + "entity_registry_enabled_default": value_params.enabled.test( + gateway, data_path + ), + "name": value_data.get(ATTR.NAME), + } + + +ENTITY_MIGRATIONS = { + "chem_alarm": { + "new_key": VALUE.ACTIVE_ALERT, + "old_name": "Chemistry Alarm", + "new_name": "Active Alert", + }, + "chem_calcium_harness": { + "new_key": VALUE.CALCIUM_HARNESS, + }, + "chem_current_orp": { + "new_key": VALUE.ORP_NOW, + "old_name": "Current ORP", + "new_name": "ORP Now", + }, + "chem_current_ph": { + "new_key": VALUE.PH_NOW, + "old_name": "Current pH", + "new_name": "pH Now", + }, + "chem_cya": { + "new_key": VALUE.CYA, + }, + "chem_orp_dosing_state": { + "new_key": VALUE.ORP_DOSING_STATE, + }, + "chem_orp_last_dose_time": { + "new_key": VALUE.ORP_LAST_DOSE_TIME, + }, + "chem_orp_last_dose_volume": { + "new_key": VALUE.ORP_LAST_DOSE_VOLUME, + }, + "chem_orp_setpoint": { + "new_key": VALUE.ORP_SETPOINT, + }, + "chem_orp_supply_level": { + "new_key": VALUE.ORP_SUPPLY_LEVEL, + }, + "chem_ph_dosing_state": { + "new_key": VALUE.PH_DOSING_STATE, + }, + "chem_ph_last_dose_time": { + "new_key": VALUE.PH_LAST_DOSE_TIME, + }, + "chem_ph_last_dose_volume": { + "new_key": VALUE.PH_LAST_DOSE_VOLUME, + }, + "chem_ph_probe_water_temp": { + "new_key": VALUE.PH_PROBE_WATER_TEMP, + }, + "chem_ph_setpoint": { + "new_key": VALUE.PH_SETPOINT, + }, + "chem_ph_supply_level": { + "new_key": VALUE.PH_SUPPLY_LEVEL, + }, + "chem_salt_tds_ppm": { + "new_key": VALUE.SALT_TDS_PPM, + }, + "chem_total_alkalinity": { + "new_key": VALUE.TOTAL_ALKALINITY, + }, + "currentGPM": { + "new_key": VALUE.GPM_NOW, + "old_name": "Current GPM", + "new_name": "GPM Now", + "device": DEVICE.PUMP, + }, + "currentRPM": { + "new_key": VALUE.RPM_NOW, + "old_name": "Current RPM", + "new_name": "RPM Now", + "device": DEVICE.PUMP, + }, + "currentWatts": { + "new_key": VALUE.WATTS_NOW, + "old_name": "Current Watts", + "new_name": "Watts Now", + "device": DEVICE.PUMP, + }, + "orp_alarm": { + "new_key": VALUE.ORP_LOW_ALARM, + "old_name": "ORP Alarm", + "new_name": "ORP LOW Alarm", + }, + "ph_alarm": { + "new_key": VALUE.PH_HIGH_ALARM, + "old_name": "pH Alarm", + "new_name": "pH HIGH Alarm", + }, + "scg_status": { + "new_key": VALUE.STATE, + "old_name": "SCG Status", + "new_name": "Chlorinator", + "device": DEVICE.SCG, + }, + "scg_level1": { + "new_key": VALUE.POOL_SETPOINT, + "old_name": "Pool SCG Level", + "new_name": "Pool Chlorinator Setpoint", + }, + "scg_level2": { + "new_key": VALUE.SPA_SETPOINT, + "old_name": "Spa SCG Level", + "new_name": "Spa Chlorinator Setpoint", + }, + "scg_salt_ppm": { + "new_key": VALUE.SALT_PPM, + "old_name": "SCG Salt", + "new_name": "Chlorinator Salt", + "device": DEVICE.SCG, + }, + "scg_super_chlor_timer": { + "new_key": VALUE.SUPER_CHLOR_TIMER, + "old_name": "SCG Super Chlorination Timer", + "new_name": "Super Chlorination Timer", + }, +} diff --git a/homeassistant/components/screenlogic/diagnostics.py b/homeassistant/components/screenlogic/diagnostics.py index ca949c4514c..92e700239ff 100644 --- a/homeassistant/components/screenlogic/diagnostics.py +++ b/homeassistant/components/screenlogic/diagnostics.py @@ -5,8 +5,8 @@ from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 955b73262a1..a29aaa9125b 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,52 +1,65 @@ """Base ScreenLogicEntity definitions.""" +from dataclasses import dataclass from datetime import datetime import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF +from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.data import ATTR +from screenlogicpy.const.msg import CODE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ScreenlogicDataUpdateCoordinator +from .const import ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +@dataclass +class ScreenLogicEntityRequiredKeyMixin: + """Mixin for required ScreenLogic entity key.""" + + data_path: ScreenLogicDataPath + + +@dataclass +class ScreenLogicEntityDescription( + EntityDescription, ScreenLogicEntityRequiredKeyMixin +): + """Base class for a ScreenLogic entity description.""" + + class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" + entity_description: ScreenLogicEntityDescription + _attr_has_entity_name = True + def __init__( self, coordinator: ScreenlogicDataUpdateCoordinator, - data_key: str, - enabled: bool = True, + entity_description: ScreenLogicEntityDescription, ) -> None: """Initialize of the entity.""" super().__init__(coordinator) - self._data_key = data_key - self._attr_entity_registry_enabled_default = enabled - self._attr_unique_id = f"{self.mac}_{self._data_key}" - - controller_type = self.config_data["controller_type"] - hardware_type = self.config_data["hardware_type"] - try: - equipment_model = EQUIPMENT.CONTROLLER_HARDWARE[controller_type][ - hardware_type - ] - except KeyError: - equipment_model = f"Unknown Model C:{controller_type} H:{hardware_type}" + self.entity_description = entity_description + self._data_path = self.entity_description.data_path + self._data_key = self._data_path[-1] + self._attr_unique_id = f"{self.mac}_{self.entity_description.key}" mac = self.mac assert mac is not None self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, manufacturer="Pentair", - model=equipment_model, - name=self.gateway_name, + model=self.gateway.controller_model, + name=self.gateway.name, sw_version=self.gateway.version, ) @@ -56,26 +69,11 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): assert self.coordinator.config_entry is not None return self.coordinator.config_entry.unique_id - @property - def config_data(self) -> dict[str | int, Any]: - """Shortcut for config data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG] - @property def gateway(self) -> ScreenLogicGateway: """Return the gateway.""" return self.coordinator.gateway - @property - def gateway_data(self) -> dict[str | int, Any]: - """Return the gateway data.""" - return self.gateway.get_data() - - @property - def gateway_name(self) -> str: - """Return the configured name of the gateway.""" - return self.gateway.name - async def _async_refresh(self) -> None: """Refresh the data from the gateway.""" await self.coordinator.async_refresh() @@ -87,20 +85,41 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Refresh from a timed called.""" await self.coordinator.async_request_refresh() + @property + def entity_data(self) -> dict: + """Shortcut to the data for this entity.""" + if (data := self.gateway.get_data(*self._data_path)) is None: + raise KeyError(f"Data not found: {self._data_path}") + return data + + +@dataclass +class ScreenLogicPushEntityRequiredKeyMixin: + """Mixin for required key for ScreenLogic push entities.""" + + subscription_code: CODE + + +@dataclass +class ScreenLogicPushEntityDescription( + ScreenLogicEntityDescription, + ScreenLogicPushEntityRequiredKeyMixin, +): + """Base class for a ScreenLogic push entity description.""" + class ScreenLogicPushEntity(ScreenlogicEntity): """Base class for all ScreenLogic push entities.""" + entity_description: ScreenLogicPushEntityDescription + def __init__( self, coordinator: ScreenlogicDataUpdateCoordinator, - data_key: str, - message_code: CODE, - enabled: bool = True, + entity_description: ScreenLogicPushEntityDescription, ) -> None: - """Initialize the entity.""" - super().__init__(coordinator, data_key, enabled) - self._update_message_code = message_code + """Initialize of the entity.""" + super().__init__(coordinator, entity_description) self._last_update_success = True @callback @@ -114,7 +133,8 @@ class ScreenLogicPushEntity(ScreenlogicEntity): await super().async_added_to_hass() self.async_on_remove( await self.gateway.async_subscribe_client( - self._async_data_updated, self._update_message_code + self._async_data_updated, + self.entity_description.subscription_code, ) ) @@ -129,17 +149,10 @@ class ScreenLogicPushEntity(ScreenlogicEntity): class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Base class for all ScreenLogic switch and light entities.""" - _attr_has_entity_name = True - - @property - def name(self) -> str: - """Get the name of the switch.""" - return self.circuit["name"] - @property def is_on(self) -> bool: """Get whether the switch is in on state.""" - return self.circuit["value"] == ON_OFF.ON + return self.entity_data[ATTR.VALUE] == ON_OFF.ON async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" @@ -149,14 +162,9 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Send the OFF command.""" await self._async_set_circuit(ON_OFF.OFF) - async def _async_set_circuit(self, circuit_value: int) -> None: - if not await self.gateway.async_set_circuit(self._data_key, circuit_value): + async def _async_set_circuit(self, state: ON_OFF) -> None: + if not await self.gateway.async_set_circuit(self._data_key, state.value): raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {circuit_value}" + f"Failed to set_circuit {self._data_key} {state.value}" ) - _LOGGER.debug("Turn %s %s", self._data_key, circuit_value) - - @property - def circuit(self) -> dict[str | int, Any]: - """Shortcut to access the circuit.""" - return self.gateway_data[SL_DATA.KEY_CIRCUITS][self._data_key] + _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 3eae12178de..3875e34fbaa 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -1,16 +1,23 @@ """Support for a ScreenLogic light 'circuit' switch.""" +from dataclasses import dataclass import logging -from screenlogicpy.const import CODE, DATA as SL_DATA, GENERIC_CIRCUIT_NAMES +from screenlogicpy.const.data import ATTR, DEVICE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ( + ColorMode, + LightEntity, + LightEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS -from .entity import ScreenLogicCircuitEntity +from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -21,26 +28,45 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicLight] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] - async_add_entities( - [ + gateway = coordinator.gateway + for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): + if circuit_data[ATTR.FUNCTION] not in LIGHT_CIRCUIT_FUNCTIONS: + continue + circuit_name = circuit_data[ATTR.NAME] + circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) + entities.append( ScreenLogicLight( coordinator, - circuit_num, - CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES, + ScreenLogicLightDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=(DEVICE.CIRCUIT, circuit_index), + key=circuit_index, + name=circuit_name, + entity_registry_enabled_default=( + circuit_name not in GENERIC_CIRCUIT_NAMES + and circuit_interface != INTERFACE.DONT_SHOW + ), + ), ) - for circuit_num, circuit in circuits.items() - if circuit["function"] in LIGHT_CIRCUIT_FUNCTIONS - ] - ) + ) + + async_add_entities(entities) + + +@dataclass +class ScreenLogicLightDescription( + LightEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic light entity.""" class ScreenLogicLight(ScreenLogicCircuitEntity, LightEntity): """Class to represent a ScreenLogic Light.""" + entity_description: ScreenLogicLightDescription _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 5b8b8369427..9fc103dc8a8 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.8.2"] + "requirements": ["screenlogicpy==0.9.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index e0d5d0e6a67..22805ffc3c1 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,25 +1,82 @@ """Support for a ScreenLogic number entity.""" +from collections.abc import Callable +from dataclasses import dataclass import logging -from screenlogicpy.const import BODY_TYPE, DATA as SL_DATA, EQUIPMENT, SCG +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import ( + DOMAIN, + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + PathPart, + SupportedValueParameters, + build_base_entity_description, + get_ha_unit, + iterate_expand_group_wildcard, + preprocess_supported_values, + realize_path_template, +) +from .entity import ScreenlogicEntity, ScreenLogicEntityDescription +from .util import cleanup_excluded_entity, generate_unique_id _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -SUPPORTED_SCG_NUMBERS = ( - "scg_level1", - "scg_level2", + +@dataclass +class SupportedNumberValueParametersMixin: + """Mixin for supported predefined data for a ScreenLogic number entity.""" + + set_value_config: tuple[str, tuple[tuple[PathPart | str | int, ...], ...]] + device_class: NumberDeviceClass | None = None + + +@dataclass +class SupportedNumberValueParameters( + SupportedValueParameters, SupportedNumberValueParametersMixin +): + """Supported predefined data for a ScreenLogic number entity.""" + + +SET_SCG_CONFIG_FUNC_DATA = ( + "async_set_scg_config", + ( + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), + ), +) + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.SCG: { + GROUP.CONFIGURATION: { + VALUE.POOL_SETPOINT: SupportedNumberValueParameters( + entity_category=EntityCategory.CONFIG, + set_value_config=SET_SCG_CONFIG_FUNC_DATA, + ), + VALUE.SPA_SETPOINT: SupportedNumberValueParameters( + entity_category=EntityCategory.CONFIG, + set_value_config=SET_SCG_CONFIG_FUNC_DATA, + ), + } + } + } ) @@ -29,66 +86,113 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicNumber] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - async_add_entities( - [ - ScreenLogicNumber(coordinator, scg_level) - for scg_level in coordinator.gateway_data[SL_DATA.KEY_SCG] - if scg_level in SUPPORTED_SCG_NUMBERS - ] + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedNumberValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + set_value_str, set_value_params = value_params.set_value_config + set_value_func = getattr(gateway, set_value_str) + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": value_params.device_class, + "native_unit_of_measurement": get_ha_unit(value_data), + "native_max_value": value_data.get(ATTR.MAX_SETPOINT), + "native_min_value": value_data.get(ATTR.MIN_SETPOINT), + "native_step": value_data.get(ATTR.STEP), + "set_value": set_value_func, + "set_value_params": set_value_params, + } + + entities.append( + ScreenLogicNumber( + coordinator, + ScreenLogicNumberDescription(**entity_description_kwargs), + ) ) + async_add_entities(entities) + + +@dataclass +class ScreenLogicNumberRequiredMixin: + """Describes a required mixin for a ScreenLogic number entity.""" + + set_value: Callable[..., bool] + set_value_params: tuple[tuple[str | int, ...], ...] + + +@dataclass +class ScreenLogicNumberDescription( + NumberEntityDescription, + ScreenLogicEntityDescription, + ScreenLogicNumberRequiredMixin, +): + """Describes a ScreenLogic number entity.""" + class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): - """Class to represent a ScreenLogic Number.""" + """Class to represent a ScreenLogic Number entity.""" - _attr_has_entity_name = True + entity_description: ScreenLogicNumberDescription - def __init__(self, coordinator, data_key, enabled=True): - """Initialize of the entity.""" - super().__init__(coordinator, data_key, enabled) - self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key) - self._attr_native_max_value = SCG.LIMIT_FOR_BODY[self._body_type] - self._attr_name = self.sensor["name"] - self._attr_native_unit_of_measurement = self.sensor["unit"] - self._attr_entity_category = EntityCategory.CONFIG + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicNumberDescription, + ) -> None: + """Initialize a ScreenLogic number entity.""" + self._set_value_func = entity_description.set_value + self._set_value_params = entity_description.set_value_params + super().__init__(coordinator, entity_description) @property def native_value(self) -> float: """Return the current value.""" - return self.sensor["value"] + return self.entity_data[ATTR.VALUE] async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Need to set both levels at the same time, so we gather - # both existing level values and override the one that changed. - levels = {} - for level in SUPPORTED_SCG_NUMBERS: - levels[level] = self.gateway_data[SL_DATA.KEY_SCG][level]["value"] - levels[self._data_key] = int(value) - if await self.coordinator.gateway.async_set_scg_config( - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], - ): - _LOGGER.debug( - "Set SCG to %i, %i", - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], + # 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_params: + data_path = realize_path_template(data_path, self._data_path) + data_value = data_path[-1] + args[data_value] = self.coordinator.gateway.get_value( + *data_path, strict=True ) + + args[self._data_key] = value + + if self._set_value_func(*args.values()): + _LOGGER.debug("Set '%s' to %s", self._data_key, value) await self._async_refresh() else: - _LOGGER.warning( - "Failed to set_scg to %i, %i", - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], - ) - - @property - def sensor(self) -> dict: - """Shortcut to access the level sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] + _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 3a9bc3cbee9..39805173961 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,76 +1,148 @@ """Support for a ScreenLogic Sensor.""" -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +import logging -from screenlogicpy.const import ( - CHEM_DOSING_STATE, - CODE, - DATA as SL_DATA, - DEVICE_TYPE, - EQUIPMENT, - STATE_TYPE, - UNIT, -) +from screenlogicpy.const.common import DEVICE_TYPE, STATE_TYPE +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.device_const.chemistry import DOSE_STATE +from screenlogicpy.device_const.pump import PUMP_TYPE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.sensor import ( + DOMAIN, SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONCENTRATION_PARTS_PER_MILLION, - PERCENTAGE, - REVOLUTIONS_PER_MINUTE, - EntityCategory, - UnitOfElectricPotential, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity, ScreenLogicPushEntity - -SUPPORTED_BASIC_SENSORS = ( - "air_temperature", - "saturation", +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + DEVICE_SUBSCRIPTION, + PathPart, + ScreenLogicDataRule, + ScreenLogicEquipmentRule, + SupportedValueParameters, + build_base_entity_description, + get_ha_unit, + iterate_expand_group_wildcard, + preprocess_supported_values, ) - -SUPPORTED_BASIC_CHEM_SENSORS = ( - "orp", - "ph", +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, ) +from .util import cleanup_excluded_entity, generate_unique_id -SUPPORTED_CHEM_SENSORS = ( - "calcium_harness", - "current_orp", - "current_ph", - "cya", - "orp_dosing_state", - "orp_last_dose_time", - "orp_last_dose_volume", - "orp_setpoint", - "orp_supply_level", - "ph_dosing_state", - "ph_last_dose_time", - "ph_last_dose_volume", - "ph_probe_water_temp", - "ph_setpoint", - "ph_supply_level", - "salt_tds_ppm", - "total_alkalinity", +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SupportedSensorValueParameters(SupportedValueParameters): + """Supported predefined data for a ScreenLogic sensor entity.""" + + device_class: SensorDeviceClass | None = None + value_modification: Callable[[int], int | str] | None = lambda val: val + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.CONTROLLER: { + GROUP.SENSOR: { + VALUE.AIR_TEMPERATURE: SupportedSensorValueParameters( + device_class=SensorDeviceClass.TEMPERATURE, entity_category=None + ), + VALUE.ORP: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + ) + ), + VALUE.PH: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + ) + ), + }, + }, + DEVICE.PUMP: { + "*": { + VALUE.WATTS_NOW: SupportedSensorValueParameters(), + VALUE.GPM_NOW: SupportedSensorValueParameters( + enabled=ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.TYPE] + != PUMP_TYPE.INTELLIFLO_VS, + (PathPart.DEVICE, PathPart.INDEX), + ) + ), + VALUE.RPM_NOW: SupportedSensorValueParameters( + enabled=ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.TYPE] + != PUMP_TYPE.INTELLIFLO_VF, + (PathPart.DEVICE, PathPart.INDEX), + ) + ), + }, + }, + DEVICE.INTELLICHEM: { + GROUP.SENSOR: { + VALUE.ORP_NOW: SupportedSensorValueParameters(), + VALUE.ORP_SUPPLY_LEVEL: SupportedSensorValueParameters( + value_modification=lambda val: val - 1 + ), + VALUE.PH_NOW: SupportedSensorValueParameters(), + VALUE.PH_PROBE_WATER_TEMP: SupportedSensorValueParameters(), + VALUE.PH_SUPPLY_LEVEL: SupportedSensorValueParameters( + value_modification=lambda val: val - 1 + ), + VALUE.SATURATION: SupportedSensorValueParameters(), + }, + GROUP.CONFIGURATION: { + VALUE.CALCIUM_HARNESS: SupportedSensorValueParameters(), + VALUE.CYA: SupportedSensorValueParameters(), + VALUE.ORP_SETPOINT: SupportedSensorValueParameters(), + VALUE.PH_SETPOINT: SupportedSensorValueParameters(), + VALUE.SALT_TDS_PPM: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + and EQUIPMENT_FLAG.CHLORINATOR not in flags, + ) + ), + VALUE.TOTAL_ALKALINITY: SupportedSensorValueParameters(), + }, + GROUP.DOSE_STATUS: { + VALUE.ORP_DOSING_STATE: SupportedSensorValueParameters( + value_modification=lambda val: DOSE_STATE(val).title, + ), + VALUE.ORP_LAST_DOSE_TIME: SupportedSensorValueParameters(), + VALUE.ORP_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), + VALUE.PH_DOSING_STATE: SupportedSensorValueParameters( + value_modification=lambda val: DOSE_STATE(val).title, + ), + VALUE.PH_LAST_DOSE_TIME: SupportedSensorValueParameters(), + VALUE.PH_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), + }, + }, + DEVICE.SCG: { + GROUP.SENSOR: { + VALUE.SALT_PPM: SupportedSensorValueParameters(), + }, + GROUP.CONFIGURATION: { + VALUE.SUPER_CHLOR_TIMER: SupportedSensorValueParameters(), + }, + }, + } ) -SUPPORTED_SCG_SENSORS = ( - "scg_salt_ppm", - "scg_super_chlor_timer", -) - -SUPPORTED_PUMP_SENSORS = ("currentWatts", "currentRPM", "currentGPM") - SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = { DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION, DEVICE_TYPE.ENUM: SensorDeviceClass.ENUM, @@ -85,18 +157,6 @@ SL_STATE_TYPE_TO_HA_STATE_CLASS = { STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, } -SL_UNIT_TO_HA_UNIT = { - UNIT.CELSIUS: UnitOfTemperature.CELSIUS, - UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, - UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT, - UNIT.WATT: UnitOfPower.WATT, - UNIT.HOUR: UnitOfTime.HOURS, - UNIT.SECOND: UnitOfTime.SECONDS, - UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE, - UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, - UNIT.PERCENT: PERCENTAGE, -} - async def async_setup_entry( hass: HomeAssistant, @@ -104,171 +164,110 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicSensorEntity] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicSensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedSensorValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) - # Generic push sensors - for sensor_name in coordinator.gateway_data[SL_DATA.KEY_SENSORS]: - if sensor_name in SUPPORTED_BASIC_SENSORS: - entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) - ) + device = data_path[0] - # While these values exist in the chemistry data, their last value doesn't - # persist there when the pump is off/there is no flow. Pulling them from - # the basic sensors keeps the 'last' value and is better for graphs. - if ( - equipment_flags & EQUIPMENT.FLAG_INTELLICHEM - and sensor_name in SUPPORTED_BASIC_CHEM_SENSORS + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path ): - entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( + value_data.get(ATTR.DEVICE_TYPE) + ), + "native_unit_of_measurement": get_ha_unit(value_data), + "options": value_data.get(ATTR.ENUM_OPTIONS), + "state_class": SL_STATE_TYPE_TO_HA_STATE_CLASS.get( + value_data.get(ATTR.STATE_TYPE) + ), + "value_mod": value_params.value_modification, + } + + if ( + sub_code := ( + value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) ) - - # Pump sensors - for pump_num, pump_data in coordinator.gateway_data[SL_DATA.KEY_PUMPS].items(): - if pump_data["data"] != 0 and "currentWatts" in pump_data: - for pump_key in pump_data: - enabled = True - # Assumptions for Intelliflow VF - if pump_data["pumpType"] == 1 and pump_key == "currentRPM": - enabled = False - # Assumptions for Intelliflow VS - if pump_data["pumpType"] == 2 and pump_key == "currentGPM": - enabled = False - if pump_key in SUPPORTED_PUMP_SENSORS: - entities.append( - ScreenLogicPumpSensor(coordinator, pump_num, pump_key, enabled) - ) - - # IntelliChem sensors - if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: - for chem_sensor_name in coordinator.gateway_data[SL_DATA.KEY_CHEMISTRY]: - enabled = True - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - if chem_sensor_name in ("salt_tds_ppm",): - enabled = False - if chem_sensor_name in SUPPORTED_CHEM_SENSORS: - entities.append( - ScreenLogicChemistrySensor( - coordinator, chem_sensor_name, CODE.CHEMISTRY_CHANGED, enabled - ) + ) is not None: + entities.append( + ScreenLogicPushSensor( + coordinator, + ScreenLogicPushSensorDescription( + subscription_code=sub_code, + **entity_description_kwargs, + ), ) - - # SCG sensors - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - entities.extend( - [ - ScreenLogicSCGSensor(coordinator, scg_sensor) - for scg_sensor in coordinator.gateway_data[SL_DATA.KEY_SCG] - if scg_sensor in SUPPORTED_SCG_SENSORS - ] - ) + ) + else: + entities.append( + ScreenLogicSensor( + coordinator, + ScreenLogicSensorDescription( + **entity_description_kwargs, + ), + ) + ) async_add_entities(entities) -class ScreenLogicSensorEntity(ScreenlogicEntity, SensorEntity): - """Base class for all ScreenLogic sensor entities.""" +@dataclass +class ScreenLogicSensorMixin: + """Mixin for SecreenLogic sensor entity.""" + value_mod: Callable[[int | str], int | str] | None = None + + +@dataclass +class ScreenLogicSensorDescription( + ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription +): + """Describes a ScreenLogic sensor.""" + + +class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): + """Representation of a ScreenLogic sensor entity.""" + + entity_description: ScreenLogicSensorDescription _attr_has_entity_name = True - @property - def name(self) -> str | None: - """Name of the sensor.""" - return self.sensor["name"] - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - sl_unit = self.sensor.get("unit") - return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) - - @property - def device_class(self) -> SensorDeviceClass | None: - """Device class of the sensor.""" - device_type = self.sensor.get("device_type") - return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) - - @property - def entity_category(self) -> EntityCategory | None: - """Entity Category of the sensor.""" - return ( - None if self._data_key == "air_temperature" else EntityCategory.DIAGNOSTIC - ) - - @property - def state_class(self) -> SensorStateClass | None: - """Return the state class of the sensor.""" - state_type = self.sensor.get("state_type") - if self._data_key == "scg_super_chlor_timer": - return None - return SL_STATE_TYPE_TO_HA_STATE_CLASS.get(state_type) - - @property - def options(self) -> list[str] | None: - """Return a set of possible options.""" - return self.sensor.get("enum_options") - @property def native_value(self) -> str | int | float: """State of the sensor.""" - return self.sensor["value"] - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key] + val = self.entity_data[ATTR.VALUE] + value_mod = self.entity_description.value_mod + return value_mod(val) if value_mod else val -class ScreenLogicStatusSensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): - """Representation of a basic ScreenLogic sensor entity.""" +@dataclass +class ScreenLogicPushSensorDescription( + ScreenLogicSensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic push sensor.""" -class ScreenLogicPumpSensor(ScreenLogicSensorEntity): - """Representation of a ScreenLogic pump sensor entity.""" +class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity): + """Representation of a ScreenLogic push sensor entity.""" - def __init__(self, coordinator, pump, key, enabled=True): - """Initialize of the pump sensor.""" - super().__init__(coordinator, f"{key}_{pump}", enabled) - self._pump_id = pump - self._key = key - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_PUMPS][self._pump_id][self._key] - - -class ScreenLogicChemistrySensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): - """Representation of a ScreenLogic IntelliChem sensor entity.""" - - def __init__(self, coordinator, key, message_code, enabled=True): - """Initialize of the pump sensor.""" - super().__init__(coordinator, f"chem_{key}", message_code, enabled) - self._key = key - - @property - def native_value(self) -> str | int | float: - """State of the sensor.""" - value = self.sensor["value"] - if "dosing_state" in self._key: - return CHEM_DOSING_STATE.NAME_FOR_NUM[value] - return (value - 1) if "supply" in self._data_key else value - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][self._key] - - -class ScreenLogicSCGSensor(ScreenLogicSensorEntity): - """Representation of ScreenLogic SCG sensor entity.""" - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] + entity_description: ScreenLogicPushSensorDescription diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 96bced70867..247ec4f2f03 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -1,21 +1,19 @@ """Support for a ScreenLogic 'circuit' switch.""" +from dataclasses import dataclass import logging -from screenlogicpy.const import ( - CODE, - DATA as SL_DATA, - GENERIC_CIRCUIT_NAMES, - INTERFACE_GROUP, -) +from screenlogicpy.const.data import ATTR, DEVICE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS -from .entity import ScreenLogicCircuitEntity +from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -26,24 +24,43 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicSwitch] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] - async_add_entities( - [ + gateway = coordinator.gateway + for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): + if circuit_data[ATTR.FUNCTION] in LIGHT_CIRCUIT_FUNCTIONS: + continue + circuit_name = circuit_data[ATTR.NAME] + circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) + entities.append( ScreenLogicSwitch( coordinator, - circuit_num, - CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES - and circuit["interface"] != INTERFACE_GROUP.DONT_SHOW, + ScreenLogicSwitchDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=(DEVICE.CIRCUIT, circuit_index), + key=circuit_index, + name=circuit_name, + entity_registry_enabled_default=( + circuit_name not in GENERIC_CIRCUIT_NAMES + and circuit_interface != INTERFACE.DONT_SHOW + ), + ), ) - for circuit_num, circuit in circuits.items() - if circuit["function"] not in LIGHT_CIRCUIT_FUNCTIONS - ] - ) + ) + + async_add_entities(entities) + + +@dataclass +class ScreenLogicSwitchDescription( + SwitchEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic switch entity.""" class ScreenLogicSwitch(ScreenLogicCircuitEntity, SwitchEntity): """Class to represent a ScreenLogic Switch.""" + + entity_description: ScreenLogicSwitchDescription diff --git a/homeassistant/components/screenlogic/util.py b/homeassistant/components/screenlogic/util.py new file mode 100644 index 00000000000..c8d9d5f0f77 --- /dev/null +++ b/homeassistant/components/screenlogic/util.py @@ -0,0 +1,40 @@ +"""Utility functions for the ScreenLogic integration.""" +import logging + +from screenlogicpy.const.data import SHARED_VALUES + +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def generate_unique_id( + device: str | int, group: str | int | None, data_key: str | int +) -> str: + """Generate new unique_id for a screenlogic entity from specified parameters.""" + if data_key in SHARED_VALUES and device is not None: + if group is not None and (isinstance(group, int) or group.isdigit()): + return f"{device}_{group}_{data_key}" + return f"{device}_{data_key}" + return str(data_key) + + +def cleanup_excluded_entity( + coordinator: ScreenlogicDataUpdateCoordinator, + platform_domain: str, + entity_key: str, +) -> None: + """Remove excluded entity if it exists.""" + assert coordinator.config_entry + entity_registry = er.async_get(coordinator.hass) + unique_id = f"{coordinator.config_entry.unique_id}_{entity_key}" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, SL_DOMAIN, unique_id + ): + _LOGGER.debug( + "Removing existing entity '%s' per data inclusion rule", entity_id + ) + entity_registry.async_remove(entity_id) diff --git a/requirements_all.txt b/requirements_all.txt index 5697dc28c94..371765cb1d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2358,7 +2358,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.0 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3416452cb9b..0e01d005d98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.0 # homeassistant.components.backup securetar==2023.3.0 diff --git a/tests/components/screenlogic/__init__.py b/tests/components/screenlogic/__init__.py index ad2b82960f0..48362722312 100644 --- a/tests/components/screenlogic/__init__.py +++ b/tests/components/screenlogic/__init__.py @@ -1 +1,67 @@ """Tests for the Screenlogic integration.""" +from collections.abc import Callable +import logging + +from tests.common import load_json_object_fixture + +MOCK_ADAPTER_NAME = "Pentair DD-EE-FF" +MOCK_ADAPTER_MAC = "aa:bb:cc:dd:ee:ff" +MOCK_ADAPTER_IP = "127.0.0.1" +MOCK_ADAPTER_PORT = 80 + +_LOGGER = logging.getLogger(__name__) + + +GATEWAY_DISCOVERY_IMPORT_PATH = "homeassistant.components.screenlogic.coordinator.async_discover_gateways_by_unique_id" + + +def num_key_string_to_int(data: dict) -> None: + """Convert all string number dict keys to integer. + + This needed for screenlogicpy's data dict format. + """ + rpl = [] + for key, value in data.items(): + if isinstance(value, dict): + num_key_string_to_int(value) + if isinstance(key, str) and key.isnumeric(): + rpl.append(key) + for k in rpl: + data[int(k)] = data.pop(k) + + return data + + +DATA_FULL_CHEM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_chem.json") +) +DATA_MIN_MIGRATION = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_min_migration.json") +) +DATA_MIN_ENTITY_CLEANUP = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_min_entity_cleanup.json") +) + + +async def stub_async_connect( + data, + self, + ip=None, + port=None, + gtype=None, + gsubtype=None, + name=MOCK_ADAPTER_NAME, + connection_closed_callback: Callable = None, +) -> bool: + """Initialize minimum attributes needed for tests.""" + self._ip = ip + self._port = port + self._type = gtype + self._subtype = gsubtype + self._name = name + self._custom_connection_closed_callback = connection_closed_callback + self._mac = MOCK_ADAPTER_MAC + self._data = data + _LOGGER.debug("Gateway mock connected") + + return True diff --git a/tests/components/screenlogic/conftest.py b/tests/components/screenlogic/conftest.py new file mode 100644 index 00000000000..3795df3dddc --- /dev/null +++ b/tests/components/screenlogic/conftest.py @@ -0,0 +1,27 @@ +"""Setup fixtures for ScreenLogic integration tests.""" +import pytest + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL + +from . import MOCK_ADAPTER_IP, MOCK_ADAPTER_MAC, MOCK_ADAPTER_NAME, MOCK_ADAPTER_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + title=MOCK_ADAPTER_NAME, + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: MOCK_ADAPTER_IP, + CONF_PORT: MOCK_ADAPTER_PORT, + }, + options={ + CONF_SCAN_INTERVAL: 30, + }, + unique_id=MOCK_ADAPTER_MAC, + entry_id="screenlogictest", + ) diff --git a/tests/components/screenlogic/fixtures/data_full_chem.json b/tests/components/screenlogic/fixtures/data_full_chem.json new file mode 100644 index 00000000000..6c9ece22fcf --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_chem.json @@ -0,0 +1,880 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 98360, + "list": [ + "INTELLIBRITE", + "INTELLIFLO_0", + "INTELLIFLO_1", + "INTELLICHEM", + "HYBRID_HEATER" + ] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 7.61, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Pool Low Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 0, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json new file mode 100644 index 00000000000..40f7dbe4ad5 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json @@ -0,0 +1,38 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { "min_setpoint": 40, "max_setpoint": 104 }, + "1": { "min_setpoint": 40, "max_setpoint": 104 } + }, + "is_celsius": { "name": "Is Celsius", "value": 0 }, + "controller_type": 13, + "hardware_type": 0 + }, + "model": { "name": "Model", "value": "EasyTouch2 8" }, + "equipment": { + "flags": 24 + } + }, + "circuit": {}, + "pump": { + "0": { "data": 0 }, + "1": { "data": 0 }, + "2": { "data": 0 }, + "3": { "data": 0 }, + "4": { "data": 0 }, + "5": { "data": 0 }, + "6": { "data": 0 }, + "7": { "data": 0 } + }, + "body": {}, + "intellichem": {}, + "scg": {} +} diff --git a/tests/components/screenlogic/fixtures/data_min_migration.json b/tests/components/screenlogic/fixtures/data_min_migration.json new file mode 100644 index 00000000000..335c98db0ae --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_min_migration.json @@ -0,0 +1,151 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 32796 + }, + "sensor": { + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": {}, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + } + }, + "1": { + "data": 0 + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": {}, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + } + } + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..05320c147e5 --- /dev/null +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -0,0 +1,960 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'ip_address': '127.0.0.1', + 'port': 80, + }), + 'disabled_by': None, + 'domain': 'screenlogic', + 'entry_id': 'screenlogictest', + 'options': dict({ + 'scan_interval': 30, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Pentair DD-EE-FF', + 'unique_id': 'aa:bb:cc:dd:ee:ff', + 'version': 1, + }), + 'data': dict({ + 'adapter': dict({ + 'firmware': dict({ + 'name': 'Protocol Adapter Firmware', + 'value': 'POOL: 5.2 Build 736.0 Rel', + }), + }), + 'body': dict({ + '0': dict({ + 'body_type': 0, + 'cool_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Pool Cool Set Point', + 'unit': '°F', + 'value': 100, + }), + 'heat_mode': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Solar Preferred', + 'Heater', + "Don't Change", + ]), + 'name': 'Pool Heat Mode', + 'value': 0, + }), + 'heat_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Pool Heat Set Point', + 'unit': '°F', + 'value': 83, + }), + 'heat_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Heater', + 'Both', + ]), + 'name': 'Pool Heat', + 'value': 0, + }), + 'last_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Last Pool Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 81, + }), + 'max_setpoint': 104, + 'min_setpoint': 40, + 'name': 'Pool', + }), + '1': dict({ + 'body_type': 1, + 'cool_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Spa Cool Set Point', + 'unit': '°F', + 'value': 69, + }), + 'heat_mode': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Solar Preferred', + 'Heater', + "Don't Change", + ]), + 'name': 'Spa Heat Mode', + 'value': 0, + }), + 'heat_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Spa Heat Set Point', + 'unit': '°F', + 'value': 94, + }), + 'heat_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Heater', + 'Both', + ]), + 'name': 'Spa Heat', + 'value': 0, + }), + 'last_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Last Spa Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 84, + }), + 'max_setpoint': 104, + 'min_setpoint': 40, + 'name': 'Spa', + }), + }), + 'circuit': dict({ + '500': dict({ + 'circuit_id': 500, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 1, + 'name_index': 71, + 'unknown_at_offset_62': 0, + 'unknown_at_offset_63': 0, + }), + 'device_id': 1, + 'function': 1, + 'interface': 1, + 'name': 'Spa', + 'value': 0, + }), + '501': dict({ + 'circuit_id': 501, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 85, + 'unknown_at_offset_94': 0, + 'unknown_at_offset_95': 0, + }), + 'device_id': 2, + 'function': 0, + 'interface': 2, + 'name': 'Waterfall', + 'value': 0, + }), + '502': dict({ + 'circuit_id': 502, + 'color': dict({ + 'color_position': 0, + 'color_set': 2, + 'color_stagger': 2, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 62, + 'unknown_at_offset_126': 0, + 'unknown_at_offset_127': 0, + }), + 'device_id': 3, + 'function': 16, + 'interface': 3, + 'name': 'Pool Light', + 'value': 0, + }), + '503': dict({ + 'circuit_id': 503, + 'color': dict({ + 'color_position': 1, + 'color_set': 6, + 'color_stagger': 10, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 73, + 'unknown_at_offset_158': 0, + 'unknown_at_offset_159': 0, + }), + 'device_id': 4, + 'function': 16, + 'interface': 3, + 'name': 'Spa Light', + 'value': 0, + }), + '504': dict({ + 'circuit_id': 504, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 240, + 'delay': 0, + 'flags': 0, + 'name_index': 21, + 'unknown_at_offset_186': 0, + 'unknown_at_offset_187': 0, + }), + 'device_id': 5, + 'function': 5, + 'interface': 0, + 'name': 'Cleaner', + 'value': 0, + }), + '505': dict({ + 'circuit_id': 505, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 1, + 'name_index': 63, + 'unknown_at_offset_214': 0, + 'unknown_at_offset_215': 0, + }), + 'device_id': 6, + 'function': 2, + 'interface': 0, + 'name': 'Pool Low', + 'value': 0, + }), + '506': dict({ + 'circuit_id': 506, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 91, + 'unknown_at_offset_246': 0, + 'unknown_at_offset_247': 0, + }), + 'device_id': 7, + 'function': 7, + 'interface': 4, + 'name': 'Yard Light', + 'value': 0, + }), + '507': dict({ + 'circuit_id': 507, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 1620, + 'delay': 0, + 'flags': 0, + 'name_index': 101, + 'unknown_at_offset_274': 0, + 'unknown_at_offset_275': 0, + }), + 'device_id': 8, + 'function': 0, + 'interface': 2, + 'name': 'Cameras', + 'value': 1, + }), + '508': dict({ + 'circuit_id': 508, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 61, + 'unknown_at_offset_306': 0, + 'unknown_at_offset_307': 0, + }), + 'device_id': 9, + 'function': 0, + 'interface': 0, + 'name': 'Pool High', + 'value': 0, + }), + '510': dict({ + 'circuit_id': 510, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 78, + 'unknown_at_offset_334': 0, + 'unknown_at_offset_335': 0, + }), + 'device_id': 11, + 'function': 14, + 'interface': 1, + 'name': 'Spillway', + 'value': 0, + }), + '511': dict({ + 'circuit_id': 511, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 61, + 'unknown_at_offset_366': 0, + 'unknown_at_offset_367': 0, + }), + 'device_id': 12, + 'function': 0, + 'interface': 5, + 'name': 'Pool High', + 'value': 0, + }), + }), + 'controller': dict({ + 'configuration': dict({ + 'body_type': dict({ + '0': dict({ + 'max_setpoint': 104, + 'min_setpoint': 40, + }), + '1': dict({ + 'max_setpoint': 104, + 'min_setpoint': 40, + }), + }), + 'circuit_count': 11, + 'color': list([ + dict({ + 'name': 'White', + 'value': list([ + 255, + 255, + 255, + ]), + }), + dict({ + 'name': 'Light Green', + 'value': list([ + 160, + 255, + 160, + ]), + }), + dict({ + 'name': 'Green', + 'value': list([ + 0, + 255, + 80, + ]), + }), + dict({ + 'name': 'Cyan', + 'value': list([ + 0, + 255, + 200, + ]), + }), + dict({ + 'name': 'Blue', + 'value': list([ + 100, + 140, + 255, + ]), + }), + dict({ + 'name': 'Lavender', + 'value': list([ + 230, + 130, + 255, + ]), + }), + dict({ + 'name': 'Magenta', + 'value': list([ + 255, + 0, + 128, + ]), + }), + dict({ + 'name': 'Light Magenta', + 'value': list([ + 255, + 180, + 210, + ]), + }), + ]), + 'color_count': 8, + 'controller_data': 0, + 'controller_type': 13, + 'generic_circuit_name': 'Water Features', + 'hardware_type': 0, + 'interface_tab_flags': 127, + 'is_celsius': dict({ + 'name': 'Is Celsius', + 'value': 0, + }), + 'remotes': 0, + 'show_alarms': 0, + 'unknown_at_offset_09': 0, + 'unknown_at_offset_10': 0, + 'unknown_at_offset_11': 0, + }), + 'controller_id': 100, + 'equipment': dict({ + 'flags': 98360, + 'list': list([ + 'INTELLIBRITE', + 'INTELLIFLO_0', + 'INTELLIFLO_1', + 'INTELLICHEM', + 'HYBRID_HEATER', + ]), + }), + 'model': dict({ + 'name': 'Model', + 'value': 'EasyTouch2 8', + }), + 'sensor': dict({ + 'active_alert': dict({ + 'device_type': 'alarm', + 'name': 'Active Alert', + 'value': 0, + }), + 'air_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Air Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 69, + }), + 'cleaner_delay': dict({ + 'name': 'Cleaner Delay', + 'value': 0, + }), + 'freeze_mode': dict({ + 'name': 'Freeze Mode', + 'value': 0, + }), + 'orp': dict({ + 'name': 'ORP', + 'state_type': 'measurement', + 'unit': 'mV', + 'value': 728, + }), + 'orp_supply_level': dict({ + 'name': 'ORP Supply Level', + 'state_type': 'measurement', + 'value': 3, + }), + 'ph': dict({ + 'name': 'pH', + 'state_type': 'measurement', + 'unit': 'pH', + 'value': 7.61, + }), + 'ph_supply_level': dict({ + 'name': 'pH Supply Level', + 'state_type': 'measurement', + 'value': 2, + }), + 'pool_delay': dict({ + 'name': 'Pool Delay', + 'value': 0, + }), + 'salt_ppm': dict({ + 'name': 'Salt', + 'state_type': 'measurement', + 'unit': 'ppm', + 'value': 0, + }), + 'saturation': dict({ + 'name': 'Saturation Index', + 'state_type': 'measurement', + 'unit': 'lsi', + 'value': 0.06, + }), + 'spa_delay': dict({ + 'name': 'Spa Delay', + 'value': 0, + }), + 'state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Unknown', + 'Ready', + 'Sync', + 'Service', + ]), + 'name': 'Controller State', + 'value': 1, + }), + }), + }), + 'intellichem': dict({ + 'alarm': dict({ + 'flags': 1, + 'flow_alarm': dict({ + 'device_type': 'alarm', + 'name': 'Flow Alarm', + 'value': 1, + }), + 'orp_high_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP HIGH Alarm', + 'value': 0, + }), + 'orp_low_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP LOW Alarm', + 'value': 0, + }), + 'orp_supply_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP Supply Alarm', + 'value': 0, + }), + 'ph_high_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH HIGH Alarm', + 'value': 0, + }), + 'ph_low_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH LOW Alarm', + 'value': 0, + }), + 'ph_supply_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH Supply Alarm', + 'value': 0, + }), + 'probe_fault_alarm': dict({ + 'device_type': 'alarm', + 'name': 'Probe Fault', + 'value': 0, + }), + }), + 'alert': dict({ + 'flags': 0, + 'orp_limit': dict({ + 'name': 'ORP Dose Limit Reached', + 'value': 0, + }), + 'ph_limit': dict({ + 'name': 'pH Dose Limit Reached', + 'value': 0, + }), + 'ph_lockout': dict({ + 'name': 'pH Lockout', + 'value': 0, + }), + }), + 'configuration': dict({ + 'calcium_harness': dict({ + 'name': 'Calcium Hardness', + 'unit': 'ppm', + 'value': 800, + }), + 'cya': dict({ + 'name': 'Cyanuric Acid', + 'unit': 'ppm', + 'value': 45, + }), + 'flags': 32, + 'orp_setpoint': dict({ + 'name': 'ORP Setpoint', + 'unit': 'mV', + 'value': 720, + }), + 'ph_setpoint': dict({ + 'name': 'pH Setpoint', + 'unit': 'pH', + 'value': 7.6, + }), + 'probe_is_celsius': 0, + 'salt_tds_ppm': dict({ + 'name': 'Salt/TDS', + 'unit': 'ppm', + 'value': 1000, + }), + 'total_alkalinity': dict({ + 'name': 'Total Alkalinity', + 'unit': 'ppm', + 'value': 45, + }), + }), + 'dose_status': dict({ + 'flags': 149, + 'orp_dosing_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Dosing', + 'Mixing', + 'Monitoring', + ]), + 'name': 'ORP Dosing State', + 'value': 2, + }), + 'orp_last_dose_time': dict({ + 'device_type': 'duration', + 'name': 'Last ORP Dose Time', + 'state_type': 'total_increasing', + 'unit': 'sec', + 'value': 4, + }), + 'orp_last_dose_volume': dict({ + 'device_type': 'volume', + 'name': 'Last ORP Dose Volume', + 'state_type': 'total_increasing', + 'unit': 'mL', + 'value': 8, + }), + 'ph_dosing_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Dosing', + 'Mixing', + 'Monitoring', + ]), + 'name': 'pH Dosing State', + 'value': 1, + }), + 'ph_last_dose_time': dict({ + 'device_type': 'duration', + 'name': 'Last pH Dose Time', + 'state_type': 'total_increasing', + 'unit': 'sec', + 'value': 5, + }), + 'ph_last_dose_volume': dict({ + 'device_type': 'volume', + 'name': 'Last pH Dose Volume', + 'state_type': 'total_increasing', + 'unit': 'mL', + 'value': 8, + }), + }), + 'firmware': dict({ + 'name': 'IntelliChem Firmware', + 'value': '1.060', + }), + 'sensor': dict({ + 'orp_now': dict({ + 'name': 'ORP Now', + 'state_type': 'measurement', + 'unit': 'mV', + 'value': 0, + }), + 'orp_supply_level': dict({ + 'name': 'ORP Supply Level', + 'state_type': 'measurement', + 'value': 3, + }), + 'ph_now': dict({ + 'name': 'pH Now', + 'state_type': 'measurement', + 'unit': 'pH', + 'value': 0.0, + }), + 'ph_probe_water_temp': dict({ + 'device_type': 'temperature', + 'name': 'pH Probe Water Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 81, + }), + 'ph_supply_level': dict({ + 'name': 'pH Supply Level', + 'state_type': 'measurement', + 'value': 2, + }), + 'saturation': dict({ + 'name': 'Saturation Index', + 'state_type': 'measurement', + 'unit': 'lsi', + 'value': 0.06, + }), + }), + 'unknown_at_offset_00': 42, + 'unknown_at_offset_04': 0, + 'unknown_at_offset_44': 0, + 'unknown_at_offset_45': 0, + 'unknown_at_offset_46': 0, + 'water_balance': dict({ + 'corrosive': dict({ + 'device_type': 'alarm', + 'name': 'SI Corrosive', + 'value': 0, + }), + 'flags': 0, + 'scaling': dict({ + 'device_type': 'alarm', + 'name': 'SI Scaling', + 'value': 0, + }), + }), + }), + 'pump': dict({ + '0': dict({ + 'data': 70, + 'gpm_now': dict({ + 'name': 'Pool Low Pump GPM Now', + 'state_type': 'measurement', + 'unit': 'gpm', + 'value': 0, + }), + 'preset': dict({ + '0': dict({ + 'device_id': 6, + 'is_rpm': 0, + 'setpoint': 63, + }), + '1': dict({ + 'device_id': 9, + 'is_rpm': 0, + 'setpoint': 72, + }), + '2': dict({ + 'device_id': 1, + 'is_rpm': 1, + 'setpoint': 3450, + }), + '3': dict({ + 'device_id': 130, + 'is_rpm': 0, + 'setpoint': 75, + }), + '4': dict({ + 'device_id': 12, + 'is_rpm': 0, + 'setpoint': 72, + }), + '5': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '6': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '7': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + }), + 'rpm_now': dict({ + 'name': 'Pool Low Pump RPM Now', + 'state_type': 'measurement', + 'unit': 'rpm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Pool Low Pump', + 'value': 0, + }), + 'type': 3, + 'unknown_at_offset_16': 0, + 'unknown_at_offset_24': 255, + 'watts_now': dict({ + 'device_type': 'power', + 'name': 'Pool Low Pump Watts Now', + 'state_type': 'measurement', + 'unit': 'W', + 'value': 0, + }), + }), + '1': dict({ + 'data': 66, + 'gpm_now': dict({ + 'name': 'Waterfall Pump GPM Now', + 'state_type': 'measurement', + 'unit': 'gpm', + 'value': 0, + }), + 'preset': dict({ + '0': dict({ + 'device_id': 2, + 'is_rpm': 1, + 'setpoint': 2700, + }), + '1': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '2': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '3': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '4': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '5': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '6': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '7': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + }), + 'rpm_now': dict({ + 'name': 'Waterfall Pump RPM Now', + 'state_type': 'measurement', + 'unit': 'rpm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Waterfall Pump', + 'value': 0, + }), + 'type': 3, + 'unknown_at_offset_16': 0, + 'unknown_at_offset_24': 255, + 'watts_now': dict({ + 'device_type': 'power', + 'name': 'Waterfall Pump Watts Now', + 'state_type': 'measurement', + 'unit': 'W', + 'value': 0, + }), + }), + '2': dict({ + 'data': 0, + }), + '3': dict({ + 'data': 0, + }), + '4': dict({ + 'data': 0, + }), + '5': dict({ + 'data': 0, + }), + '6': dict({ + 'data': 0, + }), + '7': dict({ + 'data': 0, + }), + }), + 'scg': dict({ + 'configuration': dict({ + 'pool_setpoint': dict({ + 'body_type': 0, + 'max_setpoint': 100, + 'min_setpoint': 0, + 'name': 'Pool Chlorinator Setpoint', + 'step': 5, + 'unit': '%', + 'value': 51, + }), + 'spa_setpoint': dict({ + 'body_type': 1, + 'max_setpoint': 100, + 'min_setpoint': 0, + 'name': 'Spa Chlorinator Setpoint', + 'step': 5, + 'unit': '%', + 'value': 0, + }), + 'super_chlor_timer': dict({ + 'max_setpoint': 72, + 'min_setpoint': 1, + 'name': 'Super Chlorination Timer', + 'step': 1, + 'unit': 'hr', + 'value': 0, + }), + }), + 'flags': 0, + 'scg_present': 0, + 'sensor': dict({ + 'salt_ppm': dict({ + 'name': 'Chlorinator Salt', + 'state_type': 'measurement', + 'unit': 'ppm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Chlorinator', + 'value': 0, + }), + }), + }), + }), + 'debug': dict({ + }), + }) +# --- diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index f2c39e05b48..14488c66564 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch from screenlogicpy import ScreenLogicError -from screenlogicpy.const import ( +from screenlogicpy.const.common import ( SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT, diff --git a/tests/components/screenlogic/test_data.py b/tests/components/screenlogic/test_data.py new file mode 100644 index 00000000000..9686dc81586 --- /dev/null +++ b/tests/components/screenlogic/test_data.py @@ -0,0 +1,91 @@ +"""Tests for ScreenLogic integration data processing.""" +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.screenlogic.data import PathPart, realize_path_template +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ( + DATA_MIN_ENTITY_CLEANUP, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + + +async def test_async_cleanup_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test cleanup of unused entities.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_UNUSED_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_saturation", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} Saturation Index", + "disabled_by": None, + "has_entity_name": True, + "original_name": "Saturation Index", + } + + unused_entity: er.RegistryEntry = entity_registry.async_get_or_create( + **TEST_UNUSED_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + assert unused_entity + assert unused_entity.unique_id == TEST_UNUSED_ENTRY["unique_id"] + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_MIN_ENTITY_CLEANUP, *args, **kwargs + ), + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + deleted_entity = entity_registry.async_get(unused_entity.entity_id) + assert deleted_entity is None + + +def test_realize_path_templates() -> None: + """Test path template realization.""" + assert realize_path_template( + (PathPart.DEVICE, PathPart.INDEX), (DEVICE.PUMP, 0, VALUE.WATTS_NOW) + ) == (DEVICE.PUMP, 0) + + assert realize_path_template( + (PathPart.DEVICE, PathPart.INDEX, PathPart.VALUE, ATTR.NAME_INDEX), + (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION), + ) == (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION, ATTR.NAME_INDEX) + + with pytest.raises(KeyError): + realize_path_template( + (PathPart.DEVICE, PathPart.KEY, ATTR.VALUE), + (DEVICE.ADAPTER, VALUE.FIRMWARE), + ) diff --git a/tests/components/screenlogic/test_diagnostics.py b/tests/components/screenlogic/test_diagnostics.py new file mode 100644 index 00000000000..dcbca954730 --- /dev/null +++ b/tests/components/screenlogic/test_diagnostics.py @@ -0,0 +1,56 @@ +"""Testing for ScreenLogic diagnostics.""" +from unittest.mock import DEFAULT, patch + +from screenlogicpy import ScreenLogicGateway +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import ( + DATA_FULL_CHEM, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + stub_async_connect, +) + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_config_entry.add_to_hass(hass) + + device_registry = dr.async_get(hass) + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_FULL_CHEM, *args, **kwargs + ), + is_connected=True, + _async_connected_request=DEFAULT, + get_debug=lambda self: {}, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diag == snapshot diff --git a/tests/components/screenlogic/test_init.py b/tests/components/screenlogic/test_init.py new file mode 100644 index 00000000000..3b99354a1df --- /dev/null +++ b/tests/components/screenlogic/test_init.py @@ -0,0 +1,236 @@ +"""Tests for ScreenLogic integration init.""" +from dataclasses import dataclass +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import slugify + +from . import ( + DATA_MIN_MIGRATION, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + + +@dataclass +class EntityMigrationData: + """Class to organize minimum entity data.""" + + old_name: str + old_key: str + new_name: str + new_key: str + domain: str + + +TEST_MIGRATING_ENTITIES = [ + EntityMigrationData( + "Chemistry Alarm", + "chem_alarm", + "Active Alert", + "active_alert", + BINARY_SENSOR_DOMAIN, + ), + EntityMigrationData( + "Pool Low Pump Current Watts", + "currentWatts_0", + "Pool Low Pump Watts Now", + "pump_0_watts_now", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "SCG Status", + "scg_status", + "Chlorinator", + "scg_state", + BINARY_SENSOR_DOMAIN, + ), + EntityMigrationData( + "Non-Migrating Sensor", + "nonmigrating", + "Non-Migrating Sensor", + "nonmigrating", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "Cyanuric Acid", + "chem_cya", + "Cyanuric Acid", + "chem_cya", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "Old Sensor", + "old_sensor", + "Old Sensor", + "old_sensor", + SENSOR_DOMAIN, + ), +] + +MIGRATION_CONNECT = lambda *args, **kwargs: stub_async_connect( + DATA_MIN_MIGRATION, *args, **kwargs +) + + +@pytest.mark.parametrize( + ("entity_def", "ent_data"), + [ + ( + { + "domain": ent_data.domain, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_{ent_data.old_key}", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} {ent_data.old_name}", + "disabled_by": None, + "has_entity_name": True, + "original_name": ent_data.old_name, + }, + ent_data, + ) + for ent_data in TEST_MIGRATING_ENTITIES + ], + ids=[ent_data.old_name for ent_data in TEST_MIGRATING_ENTITIES], +) +async def test_async_migrate_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_def: dict, + ent_data: EntityMigrationData, +) -> None: + """Test migration to new entity names.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_EXISTING_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_cya", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} CYA", + "disabled_by": None, + "has_entity_name": True, + "original_name": "CYA", + } + + entity_registry.async_get_or_create( + **TEST_EXISTING_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entity_def, device_id=device.id, config_entry=mock_config_entry + ) + + old_eid = f"{ent_data.domain}.{slugify(f'{MOCK_ADAPTER_NAME} {ent_data.old_name}')}" + old_uid = f"{MOCK_ADAPTER_MAC}_{ent_data.old_key}" + new_eid = f"{ent_data.domain}.{slugify(f'{MOCK_ADAPTER_NAME} {ent_data.new_name}')}" + new_uid = f"{MOCK_ADAPTER_MAC}_{ent_data.new_key}" + + assert entity.unique_id == old_uid + assert entity.entity_id == old_eid + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=MIGRATION_CONNECT, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(new_eid) + assert entity_migrated + assert entity_migrated.entity_id == new_eid + assert entity_migrated.unique_id == new_uid + assert entity_migrated.original_name == ent_data.new_name + + +async def test_entity_migration_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ENTITY_MIGRATION data guards.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_EXISTING_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_missing_device", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} Missing Migration Device", + "disabled_by": None, + "has_entity_name": True, + "original_name": "EMissing Migration Device", + } + + original_entity: er.RegistryEntry = entity_registry.async_get_or_create( + **TEST_EXISTING_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + old_eid = original_entity.entity_id + old_uid = original_entity.unique_id + + assert old_uid == f"{MOCK_ADAPTER_MAC}_missing_device" + assert ( + old_eid + == f"{SENSOR_DOMAIN}.{slugify(f'{MOCK_ADAPTER_NAME} Missing Migration Device')}" + ) + + # This patch simulates bad data being added to ENTITY_MIGRATIONS + with patch.dict( + "homeassistant.components.screenlogic.data.ENTITY_MIGRATIONS", + { + "missing_device": { + "new_key": "state", + "old_name": "Missing Migration Device", + "new_name": "Bad ENTITY_MIGRATIONS Entry", + }, + }, + ), patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=MIGRATION_CONNECT, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get( + slugify(f"{MOCK_ADAPTER_NAME} Bad ENTITY_MIGRATIONS Entry") + ) + assert entity_migrated is None + + entity_not_migrated = entity_registry.async_get(old_eid) + assert entity_not_migrated == original_entity From 602e36aa12c2218840728cea21b9db0f286d98da Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 9 Sep 2023 18:40:28 -0400 Subject: [PATCH 330/984] Add new sensors to Roborock (#99983) * Add 3 new sensor types * add state options for dock error * add unit of measurement --- homeassistant/components/roborock/sensor.py | 36 ++++++++++++++++++- .../components/roborock/strings.json | 17 +++++++++ tests/components/roborock/test_sensor.py | 3 +- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 0629839f01b..8d58ae96c45 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -4,7 +4,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from roborock.containers import RoborockErrorCode, RoborockStateCode +from roborock.containers import ( + RoborockDockErrorCode, + RoborockDockTypeCode, + RoborockErrorCode, + RoborockStateCode, +) from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -134,6 +139,35 @@ SENSOR_DESCRIPTIONS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + # Only available on some newer models + RoborockSensorDescription( + key="clean_percent", + icon="mdi:progress-check", + translation_key="clean_percent", + value_fn=lambda data: data.status.clean_percent, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + # Only available with more than just the basic dock + RoborockSensorDescription( + key="dock_error", + icon="mdi:garage-open", + translation_key="dock_error", + value_fn=lambda data: data.status.dock_error_status.name + if data.status.dock_type != RoborockDockTypeCode.no_dock + else None, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=RoborockDockErrorCode.keys(), + ), + RoborockSensorDescription( + key="mop_clean_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.status.rdt, + translation_key="mop_drying_remaining_time", + entity_category=EntityCategory.DIAGNOSTIC, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 269bbf04cf2..0170c8ac706 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -50,9 +50,26 @@ "cleaning_time": { "name": "Cleaning time" }, + "clean_percent": { + "name": "Cleaning progress" + }, + "dock_error": { + "name": "Dock error", + "state": { + "ok": "Ok", + "duct_blockage": "Duct blockage", + "water_empty": "Water empty", + "waste_water_tank_full": "Waste water tank full", + "dirty_tank_latch_open": "Dirty tank latch open", + "no_dustbin": "No dustbin" + } + }, "main_brush_time_left": { "name": "Main brush time left" }, + "mop_drying_remaining_time": { + "name": "Mop drying remaining time" + }, "side_brush_time_left": { "name": "Side brush time left" }, diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 19648343bb4..a022f0dfa51 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 11 + assert len(hass.states.async_all("sensor")) == 12 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -38,3 +38,4 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" From 3b588a839cad95d42e67eecd174cc0732db0b646 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 19:49:26 -0500 Subject: [PATCH 331/984] Bump zeroconf to 0.103.0 (#100012) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zeroconf/test_init.py | 4 +++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index e97c430d35d..d3fd3654997 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.102.0"] + "requirements": ["zeroconf==0.103.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0857591e120..f9144efba8d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.102.0 +zeroconf==0.103.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 371765cb1d1..f43b02e847e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2767,7 +2767,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.102.0 +zeroconf==0.103.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e01d005d98..f57a5ac4a3d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2043,7 +2043,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.102.0 +zeroconf==0.103.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index b07e2d5880a..a6ff257d78c 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -220,7 +220,7 @@ async def test_setup_with_overly_long_url_and_name( " string long string long string long string long string" ), ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.request", + "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -1219,6 +1219,8 @@ async def test_setup_with_disallowed_characters_in_local_name( hass.config, "location_name", "My.House", + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) From 4153181cd3c1a2ce65eb77f110bae6787c241bd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 03:17:59 -0500 Subject: [PATCH 332/984] Bump aiodiscover to 1.5.1 (#100020) changelog: https://github.com/bdraco/aiodiscover/compare/v1.4.16...v1.5.1 --- homeassistant/components/dhcp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index e65966fbaa2..3d9a5578045 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.16"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.5.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f9144efba8d..b40c0198dfe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -aiodiscover==1.4.16 +aiodiscover==1.5.1 aiohttp-cors==0.7.0 aiohttp==3.8.5 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index f43b02e847e..612cf1b1f45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobotocore==2.6.0 aiocomelit==0.0.5 # homeassistant.components.dhcp -aiodiscover==1.4.16 +aiodiscover==1.5.1 # homeassistant.components.dnsip # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f57a5ac4a3d..295f0e97ef3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -194,7 +194,7 @@ aiobotocore==2.6.0 aiocomelit==0.0.5 # homeassistant.components.dhcp -aiodiscover==1.4.16 +aiodiscover==1.5.1 # homeassistant.components.dnsip # homeassistant.components.minecraft_server From 1f3b3b1be3ffec4431d309663909963afbd4d22f Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 10 Sep 2023 10:20:26 +0200 Subject: [PATCH 333/984] Add sensor entity descriptions in Minecraft Server (#99971) * Add sensor entity descriptions * Fix review findings * Fix type of value function to avoid inline lambda if conditions and add attribute function to avoid extra sensor entity class * Correct name of binary sensor base entity * Simplify adding of entities in platforms * Do not use keyword arguments while adding entities --- .../minecraft_server/binary_sensor.py | 54 ++-- .../components/minecraft_server/entity.py | 15 +- .../components/minecraft_server/sensor.py | 235 ++++++++---------- 3 files changed, 146 insertions(+), 158 deletions(-) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 3721a50b1de..51978d388b6 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -1,7 +1,10 @@ """The Minecraft Server binary sensor platform.""" +from dataclasses import dataclass + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -12,6 +15,21 @@ from .const import DOMAIN, ICON_STATUS, KEY_STATUS from .entity import MinecraftServerEntity +@dataclass +class MinecraftServerBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Minecraft Server binary sensor entities.""" + + +BINARY_SENSOR_DESCRIPTIONS = [ + MinecraftServerBinarySensorEntityDescription( + key=KEY_STATUS, + translation_key=KEY_STATUS, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + icon=ICON_STATUS, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -20,28 +38,32 @@ async def async_setup_entry( """Set up the Minecraft Server binary sensor platform.""" server = hass.data[DOMAIN][config_entry.entry_id] - # Create entities list. - entities = [MinecraftServerStatusBinarySensor(server)] - # Add binary sensor entities. - async_add_entities(entities, True) + async_add_entities( + [ + MinecraftServerBinarySensorEntity(server, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ], + True, + ) -class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorEntity): - """Representation of a Minecraft Server status binary sensor.""" +class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntity): + """Representation of a Minecraft Server binary sensor base entity.""" - _attr_translation_key = KEY_STATUS + entity_description: MinecraftServerBinarySensorEntityDescription - def __init__(self, server: MinecraftServer) -> None: - """Initialize status binary sensor.""" - super().__init__( - server=server, - entity_type=KEY_STATUS, - icon=ICON_STATUS, - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ) + def __init__( + self, + server: MinecraftServer, + description: MinecraftServerBinarySensorEntityDescription, + ) -> None: + """Initialize binary sensor base entity.""" + super().__init__(server=server) + self.entity_description = description + self._attr_unique_id = f"{server.unique_id}-{description.key}" self._attr_is_on = False async def async_update(self) -> None: - """Update status.""" + """Update binary sensor state.""" self._attr_is_on = self._server.online diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 9048cb94004..4702b42beb9 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -19,23 +19,16 @@ class MinecraftServerEntity(Entity): def __init__( self, server: MinecraftServer, - entity_type: str, - icon: str, - device_class: str | None, ) -> None: """Initialize base entity.""" self._server = server - self._attr_icon = icon - self._attr_unique_id = f"{self._server.unique_id}-{entity_type}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._server.unique_id)}, + identifiers={(DOMAIN, server.unique_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({self._server.data.version})", - name=self._server.name, - sw_version=f"{self._server.data.protocol_version}", + model=f"Minecraft Server ({server.data.version})", + name=server.name, + sw_version=str(server.data.protocol_version), ) - self._attr_device_class = device_class - self._extra_state_attributes = None self._disconnect_dispatcher: CALLBACK_TYPE | None = None async def async_update(self) -> None: diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index e17050310a8..cb3be3e58d7 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -1,13 +1,18 @@ """The Minecraft Server sensor platform.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from collections.abc import Callable, MutableMapping +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from . import MinecraftServer +from . import MinecraftServer, MinecraftServerData from .const import ( ATTR_PLAYERS_LIST, DOMAIN, @@ -29,6 +34,84 @@ from .const import ( from .entity import MinecraftServerEntity +@dataclass +class MinecraftServerEntityDescriptionMixin: + """Mixin values for Minecraft Server entities.""" + + value_fn: Callable[[MinecraftServerData], StateType] + attributes_fn: Callable[[MinecraftServerData], MutableMapping[str, Any]] | None + + +@dataclass +class MinecraftServerSensorEntityDescription( + SensorEntityDescription, MinecraftServerEntityDescriptionMixin +): + """Class describing Minecraft Server sensor entities.""" + + +def get_extra_state_attributes_players_list( + data: MinecraftServerData, +) -> dict[str, list[str]]: + """Return players list as extra state attributes, if available.""" + extra_state_attributes = {} + players_list = data.players_list + + if players_list is not None and len(players_list) != 0: + extra_state_attributes[ATTR_PLAYERS_LIST] = players_list + + return extra_state_attributes + + +SENSOR_DESCRIPTIONS = [ + MinecraftServerSensorEntityDescription( + key=KEY_VERSION, + translation_key=KEY_VERSION, + icon=ICON_VERSION, + value_fn=lambda data: data.version, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PROTOCOL_VERSION, + translation_key=KEY_PROTOCOL_VERSION, + icon=ICON_PROTOCOL_VERSION, + value_fn=lambda data: data.protocol_version, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PLAYERS_MAX, + translation_key=KEY_PLAYERS_MAX, + native_unit_of_measurement=UNIT_PLAYERS_MAX, + icon=ICON_PLAYERS_MAX, + value_fn=lambda data: data.players_max, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_LATENCY, + translation_key=KEY_LATENCY, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + suggested_display_precision=0, + icon=ICON_LATENCY, + value_fn=lambda data: data.latency, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_MOTD, + translation_key=KEY_MOTD, + icon=ICON_MOTD, + value_fn=lambda data: data.motd, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PLAYERS_ONLINE, + translation_key=KEY_PLAYERS_ONLINE, + native_unit_of_measurement=UNIT_PLAYERS_ONLINE, + icon=ICON_PLAYERS_ONLINE, + value_fn=lambda data: data.players_online, + attributes_fn=get_extra_state_attributes_players_list, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -37,151 +120,41 @@ async def async_setup_entry( """Set up the Minecraft Server sensor platform.""" server = hass.data[DOMAIN][config_entry.entry_id] - # Create entities list. - entities = [ - MinecraftServerVersionSensor(server), - MinecraftServerProtocolVersionSensor(server), - MinecraftServerLatencySensor(server), - MinecraftServerPlayersOnlineSensor(server), - MinecraftServerPlayersMaxSensor(server), - MinecraftServerMOTDSensor(server), - ] - # Add sensor entities. - async_add_entities(entities, True) + async_add_entities( + [ + MinecraftServerSensorEntity(server, description) + for description in SENSOR_DESCRIPTIONS + ], + True, + ) class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): """Representation of a Minecraft Server sensor base entity.""" + entity_description: MinecraftServerSensorEntityDescription + def __init__( self, server: MinecraftServer, - entity_type: str, - icon: str, - unit: str | None = None, - device_class: str | None = None, + description: MinecraftServerSensorEntityDescription, ) -> None: """Initialize sensor base entity.""" - super().__init__(server, entity_type, icon, device_class) - self._attr_native_unit_of_measurement = unit + super().__init__(server) + self.entity_description = description + self._attr_unique_id = f"{server.unique_id}-{description.key}" @property def available(self) -> bool: """Return sensor availability.""" return self._server.online - -class MinecraftServerVersionSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server version sensor.""" - - _attr_translation_key = KEY_VERSION - - def __init__(self, server: MinecraftServer) -> None: - """Initialize version sensor.""" - super().__init__(server=server, entity_type=KEY_VERSION, icon=ICON_VERSION) - async def async_update(self) -> None: - """Update version.""" - self._attr_native_value = self._server.data.version + """Update sensor state.""" + self._attr_native_value = self.entity_description.value_fn(self._server.data) - -class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server protocol version sensor.""" - - _attr_translation_key = KEY_PROTOCOL_VERSION - - def __init__(self, server: MinecraftServer) -> None: - """Initialize protocol version sensor.""" - super().__init__( - server=server, - entity_type=KEY_PROTOCOL_VERSION, - icon=ICON_PROTOCOL_VERSION, - ) - - async def async_update(self) -> None: - """Update protocol version.""" - self._attr_native_value = self._server.data.protocol_version - - -class MinecraftServerLatencySensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server latency sensor.""" - - _attr_translation_key = KEY_LATENCY - - def __init__(self, server: MinecraftServer) -> None: - """Initialize latency sensor.""" - super().__init__( - server=server, - entity_type=KEY_LATENCY, - icon=ICON_LATENCY, - unit=UnitOfTime.MILLISECONDS, - ) - - async def async_update(self) -> None: - """Update latency.""" - self._attr_native_value = self._server.data.latency - - -class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server online players sensor.""" - - _attr_translation_key = KEY_PLAYERS_ONLINE - - def __init__(self, server: MinecraftServer) -> None: - """Initialize online players sensor.""" - super().__init__( - server=server, - entity_type=KEY_PLAYERS_ONLINE, - icon=ICON_PLAYERS_ONLINE, - unit=UNIT_PLAYERS_ONLINE, - ) - - async def async_update(self) -> None: - """Update online players state and device state attributes.""" - self._attr_native_value = self._server.data.players_online - - extra_state_attributes = {} - players_list = self._server.data.players_list - - if players_list is not None and len(players_list) != 0: - extra_state_attributes[ATTR_PLAYERS_LIST] = players_list - - self._attr_extra_state_attributes = extra_state_attributes - - -class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server maximum number of players sensor.""" - - _attr_translation_key = KEY_PLAYERS_MAX - - def __init__(self, server: MinecraftServer) -> None: - """Initialize maximum number of players sensor.""" - super().__init__( - server=server, - entity_type=KEY_PLAYERS_MAX, - icon=ICON_PLAYERS_MAX, - unit=UNIT_PLAYERS_MAX, - ) - - async def async_update(self) -> None: - """Update maximum number of players.""" - self._attr_native_value = self._server.data.players_max - - -class MinecraftServerMOTDSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server MOTD sensor.""" - - _attr_translation_key = KEY_MOTD - - def __init__(self, server: MinecraftServer) -> None: - """Initialize MOTD sensor.""" - super().__init__( - server=server, - entity_type=KEY_MOTD, - icon=ICON_MOTD, - ) - - async def async_update(self) -> None: - """Update MOTD.""" - self._attr_native_value = self._server.data.motd + if self.entity_description.attributes_fn: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + self._server.data + ) From c01a9987b5afd796c47804283400e90d498dbe01 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 10 Sep 2023 11:34:09 +0200 Subject: [PATCH 334/984] Add Plugwise temperature_offset number (#100029) Add temperature_offset number --- homeassistant/components/plugwise/number.py | 10 ++++++++++ .../components/plugwise/strings.json | 3 +++ tests/components/plugwise/test_number.py | 20 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 6fd3f7f92da..7e387abea02 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -60,6 +60,16 @@ NUMBER_TYPES = ( entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), + PlugwiseNumberEntityDescription( + key="temperature_offset", + translation_key="temperature_offset", + command=lambda api, number, dev_id, value: api.set_temperature_offset( + number, dev_id, value + ), + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), ) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 5210f8a6dc0..2714d657267 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -79,6 +79,9 @@ }, "max_dhw_temperature": { "name": "Domestic hot water setpoint" + }, + "temperature_offset": { + "name": "Temperature offset" } }, "select": { diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index bccf257a433..6572a0df20e 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -69,3 +69,23 @@ async def test_adam_dhw_setpoint_change( mock_smile_adam_2.set_number_setpoint.assert_called_with( "max_dhw_temperature", "056ee145a816487eaa69243c3280f8bf", 55.0 ) + + +async def test_adam_temperature_offset_change( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of number entities.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.zone_thermostat_jessie_temperature_offset", + ATTR_VALUE: 1.0, + }, + blocking=True, + ) + + assert mock_smile_adam.set_temperature_offset.call_count == 1 + mock_smile_adam.set_temperature_offset.assert_called_with( + "temperature_offset", "6a3bf693d05e48e0b460c815a4fdd09d", 1.0 + ) From 446ca2e9ad125a31e85c11097c8c9053a5a1f54d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 10 Sep 2023 12:16:59 +0200 Subject: [PATCH 335/984] Enable strict typing in Plugwise (#100033) Add plugwise to .strict-typing --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index ee97deb9af4..852ebbc0420 100644 --- a/.strict-typing +++ b/.strict-typing @@ -257,6 +257,7 @@ homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* +homeassistant.components.plugwise.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* homeassistant.components.proximity.* diff --git a/mypy.ini b/mypy.ini index eda6f35cdfa..6bade2728f4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2332,6 +2332,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.plugwise.*] +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.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true From e4af50f9555d0f1fda8793e0760b95379f030b28 Mon Sep 17 00:00:00 2001 From: fender4645 Date: Sun, 10 Sep 2023 03:58:18 -0700 Subject: [PATCH 336/984] Add debug message to doods (#100002) * Debug message if no detections found or no output file configured * fix formatting * black --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/doods/image_processing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index c94fff1124e..ba97dbe38ec 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -392,6 +392,10 @@ class Doods(ImageProcessingEntity): else: paths.append(path_template) self._save_image(image, matches, paths) + else: + _LOGGER.debug( + "Not saving image(s), no detections found or no output file configured" + ) self._matches = matches self._total_matches = total_matches From ad4619c03817b2e15220248be49c614618f288db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 08:25:13 -0500 Subject: [PATCH 337/984] Speed up serializing event messages (#100017) --- homeassistant/components/websocket_api/messages.py | 4 +++- homeassistant/core.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index e5fd5626302..1114eec4fac 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -94,7 +94,9 @@ def _cached_event_message(event: Event) -> str: The IDEN_TEMPLATE is used which will be replaced with the actual iden in cached_event_message """ - return message_to_json({"id": IDEN_TEMPLATE, "type": "event", "event": event}) + return message_to_json( + {"id": IDEN_TEMPLATE, "type": "event", "event": event.as_dict()} + ) def cached_state_diff_message(iden: int, event: Event) -> str: diff --git a/homeassistant/core.py b/homeassistant/core.py index 2ffe51a4f3a..17b8b5f2e85 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -953,7 +953,7 @@ class Event: { "event_type": self.event_type, "data": ReadOnlyDict(self.data), - "origin": str(self.origin.value), + "origin": self.origin.value, "time_fired": self.time_fired.isoformat(), "context": self.context.as_dict(), } From 5e81499855c59ad331fc6208910391d141389fda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 08:25:23 -0500 Subject: [PATCH 338/984] Avoid json_decoder_fallback in /api/states (#100018) --- homeassistant/components/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 6aead6e109f..a1a2d1107b9 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -270,7 +270,7 @@ class APIEntityStateView(HomeAssistantView): # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK - resp = self.json(hass.states.get(entity_id), status_code) + resp = self.json(hass.states.get(entity_id).as_dict(), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") From 553cdfbf9945eb9a7f5efdf9e697cca3de776dce Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 10 Sep 2023 14:29:38 +0100 Subject: [PATCH 339/984] Always update unit of measurement of the utility_meter on state change (#99102) --- .../components/utility_meter/sensor.py | 8 +++++ tests/components/utility_meter/test_sensor.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index f3e86136f5d..cd581d8c37f 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.sensor import ( SensorExtraStoredData, SensorStateClass, ) +from homeassistant.components.sensor.recorder import _suggest_report_issue from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -484,6 +485,12 @@ class UtilityMeterSensor(RestoreSensor): DATA_TARIFF_SENSORS ]: sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + if self._unit_of_measurement is None: + _LOGGER.warning( + "Source sensor %s has no unit of measurement. Please %s", + self._sensor_source_id, + _suggest_report_issue(self.hass, self._sensor_source_id), + ) if ( adjustment := self.calculate_adjustment(old_state, new_state) @@ -491,6 +498,7 @@ class UtilityMeterSensor(RestoreSensor): # If net_consumption is off, the adjustment must be non-negative self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line + self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._last_valid_state = new_state_val self.async_write_ha_state() diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index b8f197a4dee..43d68d87362 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1460,6 +1460,39 @@ def test_calculate_adjustment_invalid_new_state( assert "Invalid state unknown" in caplog.text +async def test_unit_of_measurement_missing_invalid_new_state( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a suggestion is created when new_state is missing unit_of_measurement.""" + yaml_config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + } + } + } + source_entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.states.async_set(source_entity_id, 4, {ATTR_UNIT_OF_MEASUREMENT: None}) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert ( + f"Source sensor {source_entity_id} has no unit of measurement." in caplog.text + ) + + async def test_device_id(hass: HomeAssistant) -> None: """Test for source entity device for Utility Meter.""" device_registry = dr.async_get(hass) From ccca12cf3169b2c5ab9b22879c05688a83185d0f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 10 Sep 2023 15:42:47 +0200 Subject: [PATCH 340/984] Update bthome-ble to 3.1.1 (#100042) --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 7f53a5b5f06..01db154306f 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.1.0"] + "requirements": ["bthome-ble==3.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 612cf1b1f45..00a2560c603 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -579,7 +579,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.1.0 +bthome-ble==3.1.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 295f0e97ef3..5e689e05c78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -486,7 +486,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.1.0 +bthome-ble==3.1.1 # homeassistant.components.buienradar buienradar==1.0.5 From d56ad146732beab72d66a72d2607a92aa6a669f2 Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 10 Sep 2023 09:49:56 -0400 Subject: [PATCH 341/984] Add diagnostic platform to Honeywell (#100046) Add diagnostic platform --- .../components/honeywell/diagnostics.py | 33 ++++++++++++ tests/components/honeywell/conftest.py | 43 +++++++++++++++ .../honeywell/snapshots/test_diagnostics.ambr | 53 +++++++++++++++++++ .../components/honeywell/test_diagnostics.py | 35 ++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 homeassistant/components/honeywell/diagnostics.py create mode 100644 tests/components/honeywell/snapshots/test_diagnostics.ambr create mode 100644 tests/components/honeywell/test_diagnostics.py diff --git a/homeassistant/components/honeywell/diagnostics.py b/homeassistant/components/honeywell/diagnostics.py new file mode 100644 index 00000000000..4aebfc4c905 --- /dev/null +++ b/homeassistant/components/honeywell/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for Honeywell.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import HoneywellData +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + Honeywell: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + diagnostics_data = {} + + for device, module in Honeywell.devices.items(): + diagnostics_data.update( + { + f"Device {device}": { + "UI Data": module.raw_ui_data, + "Fan Data": module.raw_fan_data, + "DR Data": module.raw_dr_data, + } + } + ) + + return diagnostics_data diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index 8406d76803a..876050586d2 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -108,6 +108,8 @@ def device(): mock_device.heat_away_temp = HEATAWAY mock_device.cool_away_temp = COOLAWAY + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} + return mock_device @@ -127,6 +129,27 @@ def device_with_outdoor_sensor(): mock_device.temperature_unit = "C" mock_device.outdoor_temperature = OUTDOORTEMP mock_device.outdoor_humidity = OUTDOORHUMIDITY + mock_device.raw_ui_data = { + "SwitchOffAllowed": True, + "SwitchAutoAllowed": True, + "SwitchCoolAllowed": True, + "SwitchHeatAllowed": True, + "SwitchEmergencyHeatAllowed": True, + "HeatUpperSetptLimit": HEATUPPERSETPOINTLIMIT, + "HeatLowerSetptLimit": HEATLOWERSETPOINTLIMIT, + "CoolUpperSetptLimit": COOLUPPERSETPOINTLIMIT, + "CoolLowerSetptLimit": COOLLOWERSETPOINTLIMIT, + "HeatNextPeriod": NEXTHEATPERIOD, + "CoolNextPeriod": NEXTCOOLPERIOD, + } + mock_device.raw_fan_data = { + "fanModeOnAllowed": True, + "fanModeAutoAllowed": True, + "fanModeCirculateAllowed": True, + } + + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} + return mock_device @@ -145,6 +168,26 @@ def another_device(): mock_device.mac_address = "macaddress1" mock_device.outdoor_temperature = None mock_device.outdoor_humidity = None + mock_device.raw_ui_data = { + "SwitchOffAllowed": True, + "SwitchAutoAllowed": True, + "SwitchCoolAllowed": True, + "SwitchHeatAllowed": True, + "SwitchEmergencyHeatAllowed": True, + "HeatUpperSetptLimit": HEATUPPERSETPOINTLIMIT, + "HeatLowerSetptLimit": HEATLOWERSETPOINTLIMIT, + "CoolUpperSetptLimit": COOLUPPERSETPOINTLIMIT, + "CoolLowerSetptLimit": COOLLOWERSETPOINTLIMIT, + "HeatNextPeriod": NEXTHEATPERIOD, + "CoolNextPeriod": NEXTCOOLPERIOD, + } + mock_device.raw_fan_data = { + "fanModeOnAllowed": True, + "fanModeAutoAllowed": True, + "fanModeCirculateAllowed": True, + } + + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} return mock_device diff --git a/tests/components/honeywell/snapshots/test_diagnostics.ambr b/tests/components/honeywell/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3077fc747de --- /dev/null +++ b/tests/components/honeywell/snapshots/test_diagnostics.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'Device 1234567': dict({ + 'DR Data': dict({ + 'CoolSetpLimit': None, + 'HeatSetpLimit': None, + }), + 'Fan Data': dict({ + 'fanModeAutoAllowed': True, + 'fanModeCirculateAllowed': True, + 'fanModeOnAllowed': True, + }), + 'UI Data': dict({ + 'CoolLowerSetptLimit': 10, + 'CoolNextPeriod': 10, + 'CoolUpperSetptLimit': 20, + 'HeatLowerSetptLimit': 20, + 'HeatNextPeriod': 10, + 'HeatUpperSetptLimit': 35, + 'SwitchAutoAllowed': True, + 'SwitchCoolAllowed': True, + 'SwitchEmergencyHeatAllowed': True, + 'SwitchHeatAllowed': True, + 'SwitchOffAllowed': True, + }), + }), + 'Device 7654321': dict({ + 'DR Data': dict({ + 'CoolSetpLimit': None, + 'HeatSetpLimit': None, + }), + 'Fan Data': dict({ + 'fanModeAutoAllowed': True, + 'fanModeCirculateAllowed': True, + 'fanModeOnAllowed': True, + }), + 'UI Data': dict({ + 'CoolLowerSetptLimit': 10, + 'CoolNextPeriod': 10, + 'CoolUpperSetptLimit': 20, + 'HeatLowerSetptLimit': 20, + 'HeatNextPeriod': 10, + 'HeatUpperSetptLimit': 35, + 'SwitchAutoAllowed': True, + 'SwitchCoolAllowed': True, + 'SwitchEmergencyHeatAllowed': True, + 'SwitchHeatAllowed': True, + 'SwitchOffAllowed': True, + }), + }), + }) +# --- diff --git a/tests/components/honeywell/test_diagnostics.py b/tests/components/honeywell/test_diagnostics.py new file mode 100644 index 00000000000..aafc50d5545 --- /dev/null +++ b/tests/components/honeywell/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Test Honeywell diagnostics.""" +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +YAML_CONFIG = {"username": "test-user", "password": "test-password"} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, +) -> None: + """Test config entry diagnostics for Honeywell.""" + + location.devices_by_id[another_device.deviceid] = another_device + 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.state is ConfigEntryState.LOADED + assert hass.states.async_entity_ids_count() == 6 + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot From 59e87c0864f5d7205866090cf05a9593f8fce942 Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 10 Sep 2023 09:58:59 -0400 Subject: [PATCH 342/984] Raise HomeAssistantError/ValueError for service calls in Honeywell (#100041) --- homeassistant/components/honeywell/climate.py | 49 +- tests/components/honeywell/test_climate.py | 477 ++++++++++-------- 2 files changed, 310 insertions(+), 216 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index b23df9f1f4b..d12d90a02c3 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -27,6 +27,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, 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 @@ -315,6 +316,9 @@ class HoneywellUSThermostat(ClimateEntity): except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) + raise ValueError( + f"Honeywell set temperature failed: invalid temperature {temperature}." + ) from err async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -328,14 +332,23 @@ class HoneywellUSThermostat(ClimateEntity): except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) + raise ValueError( + f"Honeywell set temperature failed: invalid temperature: {temperature}." + ) from err async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) + try: + await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) + except SomeComfortError as err: + raise HomeAssistantError("Honeywell could not set fan mode.") from err async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) + try: + await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) + except SomeComfortError as err: + raise HomeAssistantError("Honeywell could not set system mode.") from err async def _turn_away_mode_on(self) -> None: """Turn away on. @@ -355,13 +368,16 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True, self._heat_away_temp) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", mode, self._heat_away_temp, self._cool_away_temp, ) + raise ValueError( + f"Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {self._heat_away_temp}, Cool Temperature: {self._cool_away_temp}." + ) from err async def _turn_hold_mode_on(self) -> None: """Turn permanent hold on.""" @@ -376,10 +392,14 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error("Couldn't set permanent hold") + raise HomeAssistantError( + "Honeywell couldn't set permanent hold." + ) from err else: _LOGGER.error("Invalid system mode returned: %s", mode) + raise HomeAssistantError(f"Honeywell invalid system mode returned {mode}.") async def _turn_away_mode_off(self) -> None: """Turn away/hold off.""" @@ -388,8 +408,9 @@ class HoneywellUSThermostat(ClimateEntity): # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error("Can not stop hold mode") + raise HomeAssistantError("Honeywell could not stop hold mode") from err async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -403,14 +424,22 @@ class HoneywellUSThermostat(ClimateEntity): async def async_turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - await self._device.set_system_mode("emheat") + try: + await self._device.set_system_mode("emheat") + except SomeComfortError as err: + raise HomeAssistantError( + "Honeywell could not set system mode to aux heat." + ) from err async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - if HVACMode.HEAT in self.hvac_modes: - await self.async_set_hvac_mode(HVACMode.HEAT) - else: - await self.async_set_hvac_mode(HVACMode.OFF) + try: + if HVACMode.HEAT in self.hvac_modes: + await self.async_set_hvac_mode(HVACMode.HEAT) + else: + await self.async_set_hvac_mode(HVACMode.OFF) + except HomeAssistantError as err: + raise HomeAssistantError("Honeywell could turn off aux heat mode.") from err async def async_update(self) -> None: """Get the latest state from the service.""" diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 92caa29b71f..7bd76cb8522 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -37,6 +37,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow @@ -193,6 +194,15 @@ async def test_mode_service_calls( device.set_system_mode.assert_called_once_with("auto") device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("auto") async def test_auxheat_service_calls( @@ -211,6 +221,7 @@ async def test_auxheat_service_calls( device.set_system_mode.assert_called_once_with("emheat") device.set_system_mode.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_AUX_HEAT, @@ -219,6 +230,27 @@ async def test_auxheat_service_calls( ) device.set_system_mode.assert_called_once_with("heat") + device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: True}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("emheat") + + device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: False}, + blocking=True, + ) + async def test_fan_modes_service_calls( hass: HomeAssistant, device: MagicMock, config_entry: MagicMock @@ -256,6 +288,17 @@ async def test_fan_modes_service_calls( device.set_fan_mode.assert_called_once_with("circulate") + device.set_fan_mode.reset_mock() + + device.set_fan_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_DIFFUSE}, + blocking=True, + ) + async def test_service_calls_off_mode( hass: HomeAssistant, @@ -299,16 +342,18 @@ async def test_service_calls_off_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - 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, - ) + + with pytest.raises(ValueError): + 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) assert "Invalid temperature" in caplog.text @@ -387,7 +432,6 @@ async def test_service_calls_off_mode( device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -443,16 +487,18 @@ async def test_service_calls_cool_mode( caplog.clear() device.set_setpoint_cool.reset_mock() device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - 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, - ) + + with pytest.raises(ValueError): + 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) assert "Invalid temperature" in caplog.text @@ -474,12 +520,13 @@ async def test_service_calls_cool_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True, 12) device.set_hold_heat.assert_not_called() @@ -491,12 +538,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -504,12 +552,13 @@ async def test_service_calls_cool_mode( device.hold_heat = True device.hold_cool = True - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, + blocking=True, + ) device.set_setpoint_cool.assert_called_once() @@ -519,25 +568,25 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 caplog.clear() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() assert "Couldn't set permanent hold" in caplog.text reset_mock(device) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -546,13 +595,13 @@ async def test_service_calls_cool_mode( caplog.clear() device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -563,12 +612,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -580,13 +630,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -599,12 +649,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusCool"] = 2 device.system_mode = "Junk" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_not_called() device.set_hold_heat.assert_not_called() @@ -640,13 +691,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, - blocking=True, - ) + with pytest.raises(ValueError): + 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() assert "Invalid temperature" in caplog.text @@ -667,16 +718,17 @@ async def test_service_calls_heat_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError - 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, - ) + with pytest.raises(ValueError): + 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) assert "Invalid temperature" in caplog.text @@ -685,12 +737,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -698,12 +751,13 @@ async def test_service_calls_heat_mode( device.hold_heat = True device.hold_cool = True - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, + blocking=True, + ) device.set_setpoint_heat.assert_called_once() @@ -715,24 +769,26 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() assert "Couldn't set permanent hold" in caplog.text reset_mock(device) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True, 22) device.set_hold_cool.assert_not_called() @@ -743,12 +799,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True, 22) device.set_hold_cool.assert_not_called() @@ -757,13 +814,13 @@ async def test_service_calls_heat_mode( reset_mock(device) caplog.clear() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) device.set_hold_cool.assert_called_once_with(False) @@ -771,13 +828,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() device.set_hold_cool.reset_mock() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) assert "Can not stop hold mode" in caplog.text @@ -786,12 +843,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -802,12 +860,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -863,13 +922,13 @@ async def test_service_calls_auto_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) device.set_setpoint_heat.assert_not_called() assert "Invalid temperature" in caplog.text @@ -878,16 +937,17 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - 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, - ) + with pytest.raises(ValueError): + 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_heat.assert_not_called() assert "Invalid temperature" in caplog.text @@ -917,12 +977,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_called_once_with(True) assert "Couldn't set permanent hold" in caplog.text @@ -931,12 +992,13 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = None device.set_setpoint_cool.side_effect = None - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True, 12) device.set_hold_heat.assert_called_once_with(True, 22) @@ -944,25 +1006,26 @@ async def test_service_calls_auto_mode( reset_mock(device) caplog.clear() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) device.set_hold_cool.assert_called_once_with(False) reset_mock(device) device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -974,12 +1037,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -990,12 +1054,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() From 739eb28b90adabb394a6ff57507c60ece03a01fa Mon Sep 17 00:00:00 2001 From: Yuxiang Zhu Date: Sun, 10 Sep 2023 22:07:35 +0800 Subject: [PATCH 343/984] Make homekit RTP/RTCP source ports more deterministic (#99989) --- .../components/homekit/type_cameras.py | 4 ++-- tests/components/homekit/test_type_cameras.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 62d27245a1c..4c7ba5a7841 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -79,7 +79,7 @@ VIDEO_OUTPUT = ( "-ssrc {v_ssrc} -f rtp " "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} " "srtp://{address}:{v_port}?rtcpport={v_port}&" - "localrtcpport={v_port}&pkt_size={v_pkt_size}" + "localrtpport={v_port}&pkt_size={v_pkt_size}" ) AUDIO_OUTPUT = ( @@ -92,7 +92,7 @@ AUDIO_OUTPUT = ( "-ssrc {a_ssrc} -f rtp " "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} " "srtp://{address}:{a_port}?rtcpport={a_port}&" - "localrtcpport={a_port}&pkt_size={a_pkt_size}" + "localrtpport={a_port}&pkt_size={a_pkt_size}" ) SLOW_RESOLUTIONS = [ diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 9fcd36d06f3..fdb092467f3 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -187,11 +187,11 @@ async def test_camera_stream_source_configured( "yuv420p -r 30 -b:v 299k -bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f " "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a libopus -application lowdelay -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type " "110 -ssrc {a_ssrc} -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -344,7 +344,7 @@ async def test_camera_stream_source_found( "yuv420p -r 30 -b:v 299k -bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f " "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316" + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316" ) working_ffmpeg.open.assert_called_with( @@ -507,11 +507,11 @@ async def test_camera_stream_source_configured_and_copy_codec( "-map 0:v:0 -an -c:v copy -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -580,11 +580,11 @@ async def test_camera_stream_source_configured_and_override_profile_names( "-map 0:v:0 -an -c:v h264_v4l2m2m -profile:v 4 -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -654,11 +654,11 @@ async def test_camera_streaming_fails_after_starting_ffmpeg( "-map 0:v:0 -an -c:v h264_omx -profile:v high -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) ffmpeg_with_invalid_pid.open.assert_called_with( From b165c28a7c9717465fa6c5315cbff99fa19c4816 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 16:18:45 +0200 Subject: [PATCH 344/984] Improve Withings config flow tests (#99697) * Decouple Withings sensor tests from yaml * Improve Withings config flow tests * Improve Withings config flow tests * Fix feedback * Rename CONF_PROFILE to PROFILE --- tests/components/withings/test_config_flow.py | 247 ++++++++++++------ 1 file changed, 166 insertions(+), 81 deletions(-) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index c8f3b4bbb29..51403e67225 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,90 +1,29 @@ """Tests for config flow.""" -from http import HTTPStatus +from unittest.mock import patch -from aiohttp.test_utils import TestClient - -from homeassistant import config_entries -from homeassistant.components.withings import const -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant +from homeassistant.components.withings.const import DOMAIN, PROFILE +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH -from homeassistant.setup import async_setup_component + +from .conftest import CLIENT_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -async def test_config_non_unique_profile(hass: HomeAssistant) -> None: - """Test setup a non-unique profile.""" - config_entry = MockConfigEntry( - domain=const.DOMAIN, data={const.PROFILE: "person0"}, unique_id="0" - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": "profile"}, data={const.PROFILE: "person0"} - ) - - assert result - assert result["errors"]["base"] == "already_configured" - - -async def test_config_reauth_profile( +async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, current_request_with_host: None, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test reauth an existing profile re-creates the config entry.""" - hass_config = { - HA_DOMAIN: { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, - }, - } - await async_process_ha_core_config(hass, hass_config.get(HA_DOMAIN)) - assert await async_setup_component(hass, const.DOMAIN, hass_config) - await hass.async_block_till_done() - - config_entry = MockConfigEntry( - domain=const.DOMAIN, data={const.PROFILE: "person0"}, unique_id="0" - ) - config_entry.add_to_hass(hass) - + """Check full flow.""" result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": config_entry.entry_id, - "title_placeholders": {"name": config_entry.title}, - "unique_id": config_entry.unique_id, - }, - data={"profile": "person0"}, + DOMAIN, context={"source": SOURCE_USER} ) - assert result - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" - assert result["description_placeholders"] == {const.PROFILE: "person0"} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -93,9 +32,159 @@ async def test_config_reauth_profile( }, ) - client: TestClient = await hass_client_no_auth() - resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + + 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.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": 600, + }, + }, + ) + with patch( + "homeassistant.components.withings.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "profile" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={PROFILE: "Henk"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Henk" + assert "result" in result + assert result["result"].unique_id == "600" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + + +async def test_config_non_unique_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup a non-unique profile.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={PROFILE: "Henk"}, unique_id="0") + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": 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["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + + 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.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": 10, + }, + }, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "profile" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={PROFILE: "Henk"} + ) + + assert result + assert result["errors"]["base"] == "already_configured" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={PROFILE: "Henk 2"} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Henk 2" + assert "result" in result + assert result["result"].unique_id == "10" + + +async def test_config_reauth_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + current_request_with_host, +) -> None: + """Test reauth an existing profile re-creates the config entry.""" + config_entry.add_to_hass(hass) + + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + 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.clear_requests() @@ -114,9 +203,5 @@ async def test_config_reauth_profile( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - entries = hass.config_entries.async_entries(const.DOMAIN) - assert entries - assert entries[0].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" From 140bc03fb1b0a977e8d6c6bdf3a8bc30cf1d8586 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 10 Sep 2023 16:02:42 +0100 Subject: [PATCH 345/984] Bump systembridgeconnector to 3.8.2 (#100051) Update systembridgeconnector to 3.8.2 --- homeassistant/components/system_bridge/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index c0f89c16339..bcc6189c8ef 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.4.9"], + "requirements": ["systembridgeconnector==3.8.2"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 00a2560c603..2ec37d40392 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2505,7 +2505,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.9 +systembridgeconnector==3.8.2 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e689e05c78..525861216f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1844,7 +1844,7 @@ sunwatcher==0.2.1 surepy==0.8.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.9 +systembridgeconnector==3.8.2 # homeassistant.components.tailscale tailscale==0.2.0 From 05635c913f12c7d9b19d93fdf912c69479e56ba7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 17:10:45 +0200 Subject: [PATCH 346/984] Add device to OpenUV (#100027) --- homeassistant/components/openuv/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index cb8d1bffceb..4df91cf4e15 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -126,6 +127,11 @@ class OpenUvEntity(CoordinatorEntity): f"{coordinator.latitude}_{coordinator.longitude}_{description.key}" ) self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.latitude}_{coordinator.longitude}")}, + name="OpenUV", + entry_type=DeviceEntryType.SERVICE, + ) @callback def _handle_coordinator_update(self) -> None: From 1a5f0933978cf260d7c64c96886306785c2e57ed Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Sep 2023 17:15:46 +0200 Subject: [PATCH 347/984] Uer hass.loop.create_future() for MQTT client (#100053) --- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/mqtt/util.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9ec6447b32c..50ab9dec36f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -248,7 +248,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client_available: asyncio.Future[bool] if DATA_MQTT_AVAILABLE not in hass.data: - client_available = hass.data[DATA_MQTT_AVAILABLE] = asyncio.Future() + client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future() else: client_available = hass.data[DATA_MQTT_AVAILABLE] diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 02d9964bcd1..6e364182cb0 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -63,7 +63,9 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: state_reached_future: asyncio.Future[bool] if DATA_MQTT_AVAILABLE not in hass.data: - hass.data[DATA_MQTT_AVAILABLE] = state_reached_future = asyncio.Future() + hass.data[ + DATA_MQTT_AVAILABLE + ] = state_reached_future = hass.loop.create_future() else: state_reached_future = hass.data[DATA_MQTT_AVAILABLE] if state_reached_future.done(): From 6899245020232cf703a7bf6907e3b64f49398cad Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Sep 2023 17:16:16 +0200 Subject: [PATCH 348/984] Use hass.loop.create_future() for bluetooth (#100054) --- homeassistant/components/bluetooth/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index be35a9d255d..e364fd08e88 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -138,7 +138,7 @@ async def async_process_advertisements( timeout: int, ) -> BluetoothServiceInfoBleak: """Process advertisements until callback returns true or timeout expires.""" - done: Future[BluetoothServiceInfoBleak] = Future() + done: Future[BluetoothServiceInfoBleak] = hass.loop.create_future() @hass_callback def _async_discovered_device( From 51899ce5adff3e11ca569eeb5398cb5b8ba045ef Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 10 Sep 2023 16:32:52 +0100 Subject: [PATCH 349/984] Add System Bridge notifications (#82318) * System bridge notifications Add notify platform Add file to coverage Restore and fix lint after rebase Cleanup Use entity to register notify service Fix pylint Update package to 3.6.0 and add audio actions Update package to fix conflict Remove addition * Run pre-commit run --all-files * Update homeassistant/components/system_bridge/notify.py Co-authored-by: Joost Lekkerkerker * Format * Fix * Remove duplicate import --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + .../components/system_bridge/__init__.py | 32 +++++++- .../components/system_bridge/notify.py | 76 +++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/system_bridge/notify.py diff --git a/.coveragerc b/.coveragerc index ecc835106ff..23236891807 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1268,6 +1268,7 @@ omit = homeassistant/components/system_bridge/__init__.py homeassistant/components/system_bridge/binary_sensor.py homeassistant/components/system_bridge/coordinator.py + homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/__init__.py diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index d50540f7b42..d13f5bcbdde 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -20,7 +20,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_COMMAND, + CONF_ENTITY_ID, CONF_HOST, + CONF_NAME, CONF_PATH, CONF_PORT, CONF_URL, @@ -28,7 +30,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -40,6 +46,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.NOTIFY, Platform.SENSOR, ] @@ -142,7 +149,24 @@ async def async_setup_entry( hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Set up all platforms except notify + await hass.config_entries.async_forward_entry_setups( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) + + # Set up notify platform + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + { + CONF_NAME: f"{DOMAIN}_{coordinator.data.system.hostname}", + CONF_ENTITY_ID: entry.entry_id, + }, + hass.data[DOMAIN][entry.entry_id], + ) + ) if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL): return True @@ -277,7 +301,9 @@ async def async_setup_entry( 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) + unload_ok = await hass.config_entries.async_unload_platforms( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) if unload_ok: coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py new file mode 100644 index 00000000000..1ad071bf78f --- /dev/null +++ b/homeassistant/components/system_bridge/notify.py @@ -0,0 +1,76 @@ +"""Support for System Bridge notification service.""" +from __future__ import annotations + +import logging +from typing import Any + +from systembridgeconnector.models.notification import Notification + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + BaseNotificationService, +) +from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +ATTR_ACTIONS = "actions" +ATTR_AUDIO = "audio" +ATTR_IMAGE = "image" +ATTR_TIMEOUT = "timeout" + + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> SystemBridgeNotificationService | None: + """Get the System Bridge notification service.""" + if discovery_info is None: + return None + + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + discovery_info[CONF_ENTITY_ID] + ] + + return SystemBridgeNotificationService(coordinator) + + +class SystemBridgeNotificationService(BaseNotificationService): + """Implement the notification service for System Bridge.""" + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + ) -> None: + """Initialize the service.""" + self._coordinator: SystemBridgeDataUpdateCoordinator = coordinator + + async def async_send_message( + self, + message: str = "", + **kwargs: Any, + ) -> None: + """Send a message.""" + data = kwargs.get(ATTR_DATA, {}) or {} + + notification = Notification( + actions=data.get(ATTR_ACTIONS), + audio=data.get(ATTR_AUDIO), + icon=data.get(ATTR_ICON), + image=data.get(ATTR_IMAGE), + message=message, + timeout=data.get(ATTR_TIMEOUT), + title=kwargs.get(ATTR_TITLE, data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)), + ) + + _LOGGER.debug("Sending notification: %s", notification.json()) + + await self._coordinator.websocket_client.send_notification(notification) From 50382a609c7270baff481c7190ad02f3b1359c0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 11:24:57 -0500 Subject: [PATCH 350/984] Create recorder futures with loop.create_future() (#100049) --- homeassistant/components/recorder/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index bbaff24ff77..8aa2bce96b1 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -187,7 +187,7 @@ class Recorder(threading.Thread): self.auto_purge = auto_purge self.auto_repack = auto_repack self.keep_days = keep_days - self._hass_started: asyncio.Future[object] = asyncio.Future() + self._hass_started: asyncio.Future[object] = hass.loop.create_future() self.commit_interval = commit_interval self._queue: queue.SimpleQueue[RecorderTask] = queue.SimpleQueue() self.db_url = uri @@ -198,7 +198,7 @@ class Recorder(threading.Thread): db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected self.async_db_connected: asyncio.Future[bool] = db_connected # Database is ready to use but live migration may be in progress - self.async_db_ready: asyncio.Future[bool] = asyncio.Future() + self.async_db_ready: asyncio.Future[bool] = hass.loop.create_future() # Database is ready to use and all migration steps completed (used by tests) self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() From 63852c565fe01cb1bdd80dcd0286223dc6bdf2f5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Sep 2023 18:25:25 +0200 Subject: [PATCH 351/984] Use hass.loop.create_future() in envisalink (#100057) --- homeassistant/components/envisalink/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 55ad58a030d..b0a4619bbf9 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -124,7 +124,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zones = conf.get(CONF_ZONES) partitions = conf.get(CONF_PARTITIONS) connection_timeout = conf.get(CONF_TIMEOUT) - sync_connect: asyncio.Future[bool] = asyncio.Future() + sync_connect: asyncio.Future[bool] = hass.loop.create_future() controller = EnvisalinkAlarmPanel( host, From 7acc606dd87b6c169ad9f11176f69344e726102d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 10 Sep 2023 18:25:55 +0200 Subject: [PATCH 352/984] Remove unnecessary argument from discovergy coordinator (#100058) --- homeassistant/components/discovergy/__init__.py | 1 - homeassistant/components/discovergy/coordinator.py | 4 ---- 2 files changed, 5 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index ab892cd9324..32f696a04ce 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -62,7 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # so we have data when entities are added coordinator = DiscovergyUpdateCoordinator( hass=hass, - config_entry=entry, meter=meter, discovergy_client=discovergy_data.api_client, ) diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index d2548d0bacd..1371b1f26ac 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -8,7 +8,6 @@ from pydiscovergy import Discovergy from pydiscovergy.error import AccessTokenExpired, HTTPError from pydiscovergy.models import Meter, Reading -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,19 +20,16 @@ _LOGGER = logging.getLogger(__name__) class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): """The Discovergy update coordinator.""" - config_entry: ConfigEntry discovergy_client: Discovergy meter: Meter def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, meter: Meter, discovergy_client: Discovergy, ) -> None: """Initialize the Discovergy coordinator.""" - self.config_entry = config_entry self.meter = meter self.discovergy_client = discovergy_client From 3b25262d6cbfa3f95762223e25c58d2f6889d6f8 Mon Sep 17 00:00:00 2001 From: Tony <29752086+ms264556@users.noreply.github.com> Date: Sun, 10 Sep 2023 17:49:17 +0100 Subject: [PATCH 353/984] Address ruckus_unleashed late review (#99411) --- CODEOWNERS | 4 +- .../components/ruckus_unleashed/__init__.py | 18 +- .../ruckus_unleashed/config_flow.py | 72 +++-- .../ruckus_unleashed/coordinator.py | 7 +- .../ruckus_unleashed/device_tracker.py | 18 +- .../components/ruckus_unleashed/manifest.json | 4 +- .../components/ruckus_unleashed/strings.json | 4 +- requirements_all.txt | 3 +- requirements_test_all.txt | 3 +- .../ruckus_unleashed/test_config_flow.py | 255 +++++++++++++++--- 10 files changed, 290 insertions(+), 98 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6f7a0099494..8a454cf775a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1064,8 +1064,8 @@ build.json @home-assistant/supervisor /tests/components/rss_feed_template/ @home-assistant/core /homeassistant/components/rtsp_to_webrtc/ @allenporter /tests/components/rtsp_to_webrtc/ @allenporter -/homeassistant/components/ruckus_unleashed/ @gabe565 @lanrat -/tests/components/ruckus_unleashed/ @gabe565 @lanrat +/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 +/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/ruuvi_gateway/ @akx /tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index e71555598cb..63521a622cd 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -2,7 +2,7 @@ import logging from aioruckus import AjaxSession -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -31,16 +31,18 @@ _LOGGER = logging.getLogger(__package__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ruckus Unleashed from a config entry.""" + ruckus = AjaxSession.async_create( + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) try: - ruckus = AjaxSession.async_create( - entry.data[CONF_HOST], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) await ruckus.login() - except (ConnectionRefusedError, ConnectionError) as conerr: + except (ConnectionError, SchemaError) as conerr: + await ruckus.close() raise ConfigEntryNotReady from conerr except AuthenticationError as autherr: + await ruckus.close() raise ConfigEntryAuthFailed from autherr coordinator = RuckusUnleashedDataUpdateCoordinator(hass, ruckus=ruckus) @@ -84,7 +86,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: for listener in hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS]: listener() - await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() + await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/ruckus_unleashed/config_flow.py b/homeassistant/components/ruckus_unleashed/config_flow.py index 155eb68f593..c11e9cbe89f 100644 --- a/homeassistant/components/ruckus_unleashed/config_flow.py +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -1,9 +1,10 @@ """Config flow for Ruckus Unleashed integration.""" from collections.abc import Mapping +import logging from typing import Any from aioruckus import AjaxSession, SystemStat -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -19,6 +20,8 @@ from .const import ( KEY_SYS_TITLE, ) +_LOGGER = logging.getLogger(__package__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -38,26 +41,29 @@ async def validate_input(hass: core.HomeAssistant, data): async with AjaxSession.async_create( data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] ) as ruckus: - system_info = await ruckus.api.get_system_info( - SystemStat.SYSINFO, - ) - mesh_name = (await ruckus.api.get_mesh_info())[API_MESH_NAME] - zd_serial = system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] - return { - KEY_SYS_TITLE: mesh_name, - KEY_SYS_SERIAL: zd_serial, - } + mesh_info = await ruckus.api.get_mesh_info() + system_info = await ruckus.api.get_system_info(SystemStat.SYSINFO) except AuthenticationError as autherr: raise InvalidAuth from autherr - except (ConnectionRefusedError, ConnectionError, KeyError) as connerr: + except (ConnectionError, SchemaError) as connerr: raise CannotConnect from connerr + mesh_name = mesh_info[API_MESH_NAME] + zd_serial = system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] + + return { + KEY_SYS_TITLE: mesh_name, + KEY_SYS_SERIAL: zd_serial, + } + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Ruckus Unleashed.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -70,30 +76,40 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" else: - await self.async_set_unique_id(info[KEY_SYS_SERIAL]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=info[KEY_SYS_TITLE], data=user_input - ) + if self._reauth_entry is None: + await self.async_set_unique_id(info[KEY_SYS_SERIAL]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=info[KEY_SYS_TITLE], data=user_input + ) + if info[KEY_SYS_SERIAL] == self._reauth_entry.unique_id: + 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") + errors["base"] = "invalid_host" + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} + ) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=data_schema, errors=errors ) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Dialog that informs the user that reauth is required.""" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=DATA_SCHEMA, - ) + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) return await self.async_step_user() diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index 29df676cb76..7c11aac7f68 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -3,9 +3,10 @@ from datetime import timedelta import logging from aioruckus import AjaxSession -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_CLIENT_MAC, DOMAIN, KEY_SYS_CLIENTS, SCAN_INTERVAL @@ -40,6 +41,6 @@ class RuckusUnleashedDataUpdateCoordinator(DataUpdateCoordinator): try: return {KEY_SYS_CLIENTS: await self._fetch_clients()} except AuthenticationError as autherror: - raise UpdateFailed(autherror) from autherror - except (ConnectionRefusedError, ConnectionError) as conerr: + raise ConfigEntryAuthFailed(autherror) from autherror + except (ConnectionError, SchemaError) as conerr: raise UpdateFailed(conerr) from conerr diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 0e0d2f103c4..df5027ebaa8 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -103,20 +103,16 @@ class RuckusUnleashedDevice(CoordinatorEntity, ScannerEntity): @property def name(self) -> str: """Return the name.""" - return ( - self._name - if not self.is_connected - else self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_HOSTNAME] - ) + if not self.is_connected: + return self._name + return self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_HOSTNAME] @property - def ip_address(self) -> str: + def ip_address(self) -> str | None: """Return the ip address.""" - return ( - self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_IP] - if self.is_connected - else None - ) + if not self.is_connected: + return None + return self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_IP] @property def is_connected(self) -> bool: diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 8ff69fb1aa9..edaf0aa95d2 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -1,11 +1,11 @@ { "domain": "ruckus_unleashed", "name": "Ruckus Unleashed", - "codeowners": ["@gabe565", "@lanrat"], + "codeowners": ["@lanrat", "@ms264556", "@gabe565"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ruckus_unleashed", "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioruckus", "xmltodict"], - "requirements": ["aioruckus==0.31", "xmltodict==0.13.0"] + "requirements": ["aioruckus==0.34"] } diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index d6e3212b4ea..769cde67d7a 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -12,10 +12,12 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 2ec37d40392..169e88edcb9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -334,7 +334,7 @@ aiorecollect==2023.09.0 aioridwell==2023.07.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.31 +aioruckus==0.34 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -2723,7 +2723,6 @@ xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest -# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 525861216f8..1bb45b4996c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ aiorecollect==2023.09.0 aioridwell==2023.07.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.31 +aioruckus==0.34 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -2011,7 +2011,6 @@ xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest -# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate diff --git a/tests/components/ruckus_unleashed/test_config_flow.py b/tests/components/ruckus_unleashed/test_config_flow.py index c55d531b0cb..cd74395fa66 100644 --- a/tests/components/ruckus_unleashed/test_config_flow.py +++ b/tests/components/ruckus_unleashed/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Ruckus Unleashed config flow.""" +from copy import deepcopy from datetime import timedelta from unittest.mock import AsyncMock, patch @@ -10,12 +11,22 @@ from aioruckus.const import ( from aioruckus.exceptions import AuthenticationError from homeassistant import config_entries, data_entry_flow -from homeassistant.components.ruckus_unleashed.const import DOMAIN +from homeassistant.components.ruckus_unleashed.const import ( + API_SYS_SYSINFO, + API_SYS_SYSINFO_SERIAL, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import utcnow -from . import CONFIG, DEFAULT_TITLE, RuckusAjaxApiPatchContext, mock_config_entry +from . import ( + CONFIG, + DEFAULT_SYSTEM_INFO, + DEFAULT_TITLE, + RuckusAjaxApiPatchContext, + mock_config_entry, +) from tests.common import async_fire_time_changed @@ -25,7 +36,7 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with RuckusAjaxApiPatchContext(), patch( @@ -37,12 +48,12 @@ async def test_form(hass: HomeAssistant) -> None: CONFIG, ) await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == DEFAULT_TITLE - assert result2["data"] == CONFIG assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_TITLE + assert result2["data"] == CONFIG + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" @@ -58,7 +69,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} @@ -68,7 +79,13 @@ async def test_form_user_reauth(hass: HomeAssistant) -> None: entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, ) flows = hass.config_entries.flow.async_progress() @@ -76,20 +93,181 @@ async def test_form_user_reauth(hass: HomeAssistant) -> None: assert "flow_id" in flows[0] assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "user" + assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( - flows[0]["flow_id"], - user_input={ - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "new_name", - CONF_PASSWORD: "new_pass", + with RuckusAjaxApiPatchContext(): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_form_user_reauth_different_unique_id(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, }, + data=entry.data, ) - await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + system_info = deepcopy(DEFAULT_SYSTEM_INFO) + system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] = "000000000" + with RuckusAjaxApiPatchContext(system_info=system_info): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_host"} + + +async def test_form_user_reauth_invalid_auth(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=AuthenticationError(ERROR_LOGIN_INCORRECT)) + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_user_reauth_cannot_connect(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=ConnectionError(ERROR_CONNECT_TIMEOUT)) + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_user_reauth_general_exception(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext(login_mock=AsyncMock(side_effect=Exception)): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -106,10 +284,27 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} +async def test_form_general_exception(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with RuckusAjaxApiPatchContext(login_mock=AsyncMock(side_effect=Exception)): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + + async def test_form_unexpected_response(hass: HomeAssistant) -> None: """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( @@ -126,25 +321,7 @@ async def test_form_unexpected_response(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_cannot_connect_unknown_serial(hass: HomeAssistant) -> None: - """Test we handle cannot connect error on invalid serial number.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - with RuckusAjaxApiPatchContext(system_info={}): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG, - ) - - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} @@ -167,7 +344,7 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( @@ -175,5 +352,5 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "abort" + assert result2["type"] == data_entry_flow.FlowResultType.ABORT assert result2["reason"] == "already_configured" From 4f0cd5589cac4ee906f75d16e1fc7fe87ae26bb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 12:01:12 -0500 Subject: [PATCH 354/984] Bump aiohomekit to 3.0.3 (#100047) --- homeassistant/components/homekit_controller/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_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9567ff83cea..c99142da475 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.2"], + "requirements": ["aiohomekit==3.0.3"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 169e88edcb9..4be58e257b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.2 +aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bb45b4996c..b65dcc862d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.2 +aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http From a5a82b94acbf844a0f903501c70f5f2ef54de969 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 10 Sep 2023 19:09:21 +0200 Subject: [PATCH 355/984] Bump aiovodafone to 0.2.0 (#100062) bump aiovodafone to 0.2.0 --- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 5470cdd684c..68e7665b5ac 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.1.0"] + "requirements": ["aiovodafone==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4be58e257b8..b3d7ac2a062 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -370,7 +370,7 @@ aiounifi==61 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.1.0 +aiovodafone==0.2.0 # homeassistant.components.waqi aiowaqi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b65dcc862d3..5651be57021 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -345,7 +345,7 @@ aiounifi==61 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.1.0 +aiovodafone==0.2.0 # homeassistant.components.waqi aiowaqi==0.2.1 From 3238386f482c134ec9d48286f41d4be0116caac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 11 Sep 2023 02:31:11 +0900 Subject: [PATCH 356/984] Add water heater support to Airzone (#98401) Co-authored-by: J. Nick Koston --- homeassistant/components/airzone/__init__.py | 1 + homeassistant/components/airzone/entity.py | 16 ++ .../components/airzone/water_heater.py | 131 ++++++++++ tests/components/airzone/test_water_heater.py | 228 ++++++++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 homeassistant/components/airzone/water_heater.py create mode 100644 tests/components/airzone/test_water_heater.py diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index de75bf03d45..1a54be0ac41 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -24,6 +24,7 @@ PLATFORMS: list[Platform] = [ Platform.CLIMATE, Platform.SELECT, Platform.SENSOR, + Platform.WATER_HEATER, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 267cd210ff0..2310d5fb5a4 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -106,6 +106,22 @@ class AirzoneHotWaterEntity(AirzoneEntity): """Return DHW value by key.""" return self.coordinator.data[AZD_HOT_WATER].get(key) + async def _async_update_dhw_params(self, params: dict[str, Any]) -> None: + """Send DHW parameters to API.""" + _params = { + API_SYSTEM_ID: 0, + **params, + } + _LOGGER.debug("update_dhw_params=%s", _params) + try: + await self.coordinator.airzone.set_dhw_parameters(_params) + except AirzoneError as error: + raise HomeAssistantError( + f"Failed to set dhw {self.name}: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + class AirzoneWebServerEntity(AirzoneEntity): """Define an Airzone WebServer entity.""" diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py new file mode 100644 index 00000000000..b19aa36449c --- /dev/null +++ b/homeassistant/components/airzone/water_heater.py @@ -0,0 +1,131 @@ +"""Support for the Airzone water heater.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone.common import HotWaterOperation +from aioairzone.const import ( + API_ACS_ON, + API_ACS_POWER_MODE, + API_ACS_SET_POINT, + AZD_HOT_WATER, + AZD_NAME, + AZD_OPERATION, + AZD_OPERATIONS, + AZD_TEMP, + AZD_TEMP_MAX, + AZD_TEMP_MIN, + AZD_TEMP_SET, + AZD_TEMP_UNIT, +) + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneHotWaterEntity + +OPERATION_LIB_TO_HASS: Final[dict[HotWaterOperation, str]] = { + HotWaterOperation.Off: STATE_OFF, + HotWaterOperation.On: STATE_ECO, + HotWaterOperation.Powerful: STATE_PERFORMANCE, +} + +OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = { + STATE_OFF: { + API_ACS_ON: 0, + }, + STATE_ECO: { + API_ACS_ON: 1, + API_ACS_POWER_MODE: 0, + }, + STATE_PERFORMANCE: { + API_ACS_ON: 1, + API_ACS_POWER_MODE: 1, + }, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone sensors from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + if AZD_HOT_WATER in coordinator.data: + async_add_entities([AirzoneWaterHeater(coordinator, entry)]) + + +class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity): + """Define an Airzone Water Heater.""" + + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize Airzone water heater entity.""" + super().__init__(coordinator, entry) + + self._attr_name = self.get_airzone_value(AZD_NAME) + self._attr_unique_id = f"{self._attr_unique_id}_dhw" + self._attr_operation_list = [ + OPERATION_LIB_TO_HASS[operation] + for operation in self.get_airzone_value(AZD_OPERATIONS) + ] + self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ + self.get_airzone_value(AZD_TEMP_UNIT) + ] + + self._async_update_attrs() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self._async_update_dhw_params({API_ACS_ON: 0}) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self._async_update_dhw_params({API_ACS_ON: 1}) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + params = OPERATION_MODE_TO_DHW_PARAMS.get(operation_mode, {}) + await self._async_update_dhw_params(params) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + params: dict[str, Any] = {} + if ATTR_TEMPERATURE in kwargs: + params[API_ACS_SET_POINT] = kwargs[ATTR_TEMPERATURE] + await self._async_update_dhw_params(params) + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update water heater attributes.""" + self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) + self._attr_current_operation = OPERATION_LIB_TO_HASS[ + self.get_airzone_value(AZD_OPERATION) + ] + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) diff --git a/tests/components/airzone/test_water_heater.py b/tests/components/airzone/test_water_heater.py new file mode 100644 index 00000000000..a1157192f23 --- /dev/null +++ b/tests/components/airzone/test_water_heater.py @@ -0,0 +1,228 @@ +"""The water heater tests for the Airzone platform.""" +from unittest.mock import patch + +from aioairzone.const import ( + API_ACS_ON, + API_ACS_POWER_MODE, + API_ACS_SET_POINT, + API_DATA, + API_SYSTEM_ID, +) +from aioairzone.exceptions import AirzoneError +import pytest + +from homeassistant.components.water_heater import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_OPERATION_MODE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_PERFORMANCE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .util import async_init_integration + + +async def test_airzone_create_water_heater(hass: HomeAssistant) -> None: + """Test creation of water heater.""" + + await async_init_integration(hass) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 43 + assert state.attributes[ATTR_MAX_TEMP] == 75 + assert state.attributes[ATTR_MIN_TEMP] == 30 + assert state.attributes[ATTR_TEMPERATURE] == 45 + + +async def test_airzone_water_heater_turn_on_off(hass: HomeAssistant) -> None: + """Test turning on/off.""" + + await async_init_integration(hass) + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_OFF + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + + +async def test_airzone_water_heater_set_operation(hass: HomeAssistant) -> None: + """Test setting the Operation mode.""" + + await async_init_integration(hass) + + HVAC_MOCK_1 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_1, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_OFF, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_OFF + + HVAC_MOCK_2 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + API_ACS_POWER_MODE: 1, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_2, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_PERFORMANCE, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_PERFORMANCE + + HVAC_MOCK_3 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + API_ACS_POWER_MODE: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_3, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_ECO, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + + +async def test_airzone_water_heater_set_temp(hass: HomeAssistant) -> None: + """Test setting the target temperature.""" + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_SET_POINT: 35, + } + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_TEMPERATURE: 35, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.attributes[ATTR_TEMPERATURE] == 35 + + +async def test_airzone_water_heater_set_temp_error(hass: HomeAssistant) -> None: + """Test error when setting the target temperature.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + side_effect=AirzoneError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_TEMPERATURE: 80, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.attributes[ATTR_TEMPERATURE] == 45 From 3b8d99dcd85cbe1139d8e6bc80acd54101fae043 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 13:46:55 -0500 Subject: [PATCH 357/984] Add __slots__ to translation cache (#100069) --- homeassistant/helpers/translation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 79ac3a0c5b7..41ad591d878 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -190,6 +190,8 @@ async def _async_get_component_strings( class _TranslationCache: """Cache for flattened translations.""" + __slots__ = ("hass", "loaded", "cache") + def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" self.hass = hass From 02a4289c6e61ba131a7be49753aa52dc7fc806f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 14:32:40 -0500 Subject: [PATCH 358/984] Bump zeroconf to 0.104.0 (#100068) --- 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 d3fd3654997..7d6cc32c8f1 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.103.0"] + "requirements": ["zeroconf==0.104.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b40c0198dfe..38aea19e10c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.103.0 +zeroconf==0.104.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 b3d7ac2a062..f340aab615b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2766,7 +2766,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.103.0 +zeroconf==0.104.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5651be57021..b70f815c1ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2042,7 +2042,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.103.0 +zeroconf==0.104.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 2bda34b98ab856730149d5e1983cf4af9a1b9da1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 14:45:37 -0500 Subject: [PATCH 359/984] Bump flux_led to 1.0.4 (#100050) --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index d3274738f75..977f6eefe07 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -54,5 +54,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux-led==1.0.2"] + "requirements": ["flux-led==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index f340aab615b..8ea3eb02176 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -808,7 +808,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux-led==1.0.2 +flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b70f815c1ac..b3b57707332 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -636,7 +636,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux-led==1.0.2 +flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder From 4474face882cefe5ccda22b5fad8d37a4d9fc317 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 Sep 2023 22:23:18 +0200 Subject: [PATCH 360/984] Bump tibdex/github-app-token from 1.8.0 to 1.8.2 (#99434) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5fb977f74d1..a0a86d0e868 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: id: token # Pinned to a specific version of the action for security reasons # v1.7.0 - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + uses: tibdex/github-app-token@0d49dd721133f900ebd5e0dff2810704e8defbc6 with: app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} From 80e05716c0cfc35ba41c9c421a45b937ad6627cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 16:38:39 -0500 Subject: [PATCH 361/984] Bump dbus-fast to 2.2.0 (#100076) --- 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 e5df324ec02..8cc2a7adb65 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", - "dbus-fast==2.0.1" + "dbus-fast==2.2.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 38aea19e10c..74aca53df9c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.0.1 +dbus-fast==2.2.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8ea3eb02176..77d67f85675 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.0.1 +dbus-fast==2.2.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3b57707332..054a38314a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -526,7 +526,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.0.1 +dbus-fast==2.2.0 # homeassistant.components.debugpy debugpy==1.6.7 From 45fc158823b17f9875f90dae417236dab2846dae Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 11 Sep 2023 06:31:58 +0800 Subject: [PATCH 362/984] Add yolink siren battery entity (#99310) --- homeassistant/components/yolink/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index e4d0aa38fbe..451b486acd2 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -92,6 +92,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_POWER_FAILURE_ALARM, + ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, From 4ebb6bb82324e9ed05360f90e183ac7ed75e6dd7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Sep 2023 00:56:12 +0200 Subject: [PATCH 363/984] Add sensors to Trafikverket Camera (#100078) * Add sensors to Trafikverket Camera * Remove active * Fix test len --- .../components/trafikverket_camera/const.py | 2 +- .../components/trafikverket_camera/sensor.py | 139 ++++++++++++++++++ .../trafikverket_camera/strings.json | 20 +++ .../trafikverket_camera/test_recorder.py | 11 +- .../trafikverket_camera/test_sensor.py | 29 ++++ 5 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/trafikverket_camera/sensor.py create mode 100644 tests/components/trafikverket_camera/test_sensor.py diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py index 6657ab1a853..388df241d99 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform DOMAIN = "trafikverket_camera" CONF_LOCATION = "location" -PLATFORMS = [Platform.CAMERA] +PLATFORMS = [Platform.CAMERA, Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py new file mode 100644 index 00000000000..eee2f353de5 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -0,0 +1,139 @@ +"""Sensor platform for Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CameraData, TVDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass +class DeviceBaseEntityDescriptionMixin: + """Mixin for required Trafikverket Camera base description keys.""" + + value_fn: Callable[[CameraData], StateType | datetime] + + +@dataclass +class TVCameraSensorEntityDescription( + SensorEntityDescription, DeviceBaseEntityDescriptionMixin +): + """Describes Trafikverket Camera sensor entity.""" + + +SENSOR_TYPES: tuple[TVCameraSensorEntityDescription, ...] = ( + TVCameraSensorEntityDescription( + key="direction", + translation_key="direction", + native_unit_of_measurement=DEGREE, + icon="mdi:sign-direction", + value_fn=lambda data: data.data.direction, + ), + TVCameraSensorEntityDescription( + key="modified", + translation_key="modified", + icon="mdi:camera-retake-outline", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.data.modified, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="photo_time", + translation_key="photo_time", + icon="mdi:camera-timer", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.data.phototime, + ), + TVCameraSensorEntityDescription( + key="photo_url", + translation_key="photo_url", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.photourl, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="status", + translation_key="status", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.status, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="camera_type", + translation_key="camera_type", + icon="mdi:camera-iris", + value_fn=lambda data: data.data.camera_type, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Trafikverket Camera sensor platform.""" + + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TrafikverketCameraSensor(coordinator, entry.entry_id, entry.title, description) + for description in SENSOR_TYPES + ) + + +class TrafikverketCameraSensor( + CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity +): + """Representation of a Trafikverket Camera Sensor.""" + + entity_description: TVCameraSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + name: str, + entity_description: TVCameraSensorEntityDescription, + ) -> None: + """Initiate Trafikverket Camera Sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Trafikverket", + model="v1.0", + name=name, + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + self._update_attr() + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + + @callback + def _handle_coordinator_update(self) -> None: + self._update_attr() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index c128f7729bc..27360100a29 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -46,6 +46,26 @@ } } } + }, + "sensor": { + "direction": { + "name": "Direction" + }, + "modified": { + "name": "Modified" + }, + "photo_time": { + "name": "Photo time" + }, + "photo_url": { + "name": "Photo url" + }, + "status": { + "name": "Status" + }, + "camera_type": { + "name": "Camera type" + } } } } diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index 021433b33e7..5dff358d974 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -16,6 +16,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_exclude_attributes( recorder_mock: Recorder, + entity_registry_enabled_by_default: None, hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, @@ -37,10 +38,12 @@ async def test_exclude_attributes( None, hass.states.async_entity_ids(), ) - assert len(states) == 1 + assert len(states) == 7 assert states.get("camera.test_location") for entity_states in states.values(): for state in entity_states: - assert "location" not in state.attributes - assert "description" not in state.attributes - assert "type" in state.attributes + if state.entity_id == "camera.test_location": + assert "location" not in state.attributes + assert "description" not in state.attributes + assert "type" in state.attributes + break diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py new file mode 100644 index 00000000000..cc97f4dbdcb --- /dev/null +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -0,0 +1,29 @@ +"""The test for the sensibo select platform.""" +from __future__ import annotations + +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_camera: CameraInfo, +) -> None: + """Test the Trafikverket Camera sensor.""" + + state = hass.states.get("sensor.test_location_direction") + assert state.state == "180" + state = hass.states.get("sensor.test_location_modified") + assert state.state == "2022-04-04T04:04:04+00:00" + state = hass.states.get("sensor.test_location_photo_time") + assert state.state == "2022-04-04T04:04:04+00:00" + state = hass.states.get("sensor.test_location_photo_url") + assert state.state == "https://www.testurl.com/test_photo.jpg" + state = hass.states.get("sensor.test_location_status") + assert state.state == "Running" + state = hass.states.get("sensor.test_location_camera_type") + assert state.state == "Road" From 0fae65abde2c7f64a67de298255f496506e4bd9d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Sep 2023 01:10:59 +0200 Subject: [PATCH 364/984] Fix missed name to translation key in Sensibo (#100080) --- homeassistant/components/sensibo/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 547504d7889..f6d62d79dff 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -232,7 +232,7 @@ ELEMENT_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( key="ethanol", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, - name="Ethanol", + translation_key="ethanol", value_fn=lambda data: data.etoh, extra_fn=None, ), From 954293f77ee5dfdc905ee7c1b265c97cb3520465 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Sep 2023 01:12:19 +0200 Subject: [PATCH 365/984] Add binary sensors to Trafikverket Camera (#100082) --- .../trafikverket_camera/binary_sensor.py | 97 +++++++++++++++++++ .../components/trafikverket_camera/const.py | 2 +- .../trafikverket_camera/strings.json | 5 + .../trafikverket_camera/test_binary_sensor.py | 20 ++++ .../trafikverket_camera/test_recorder.py | 2 +- 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/trafikverket_camera/binary_sensor.py create mode 100644 tests/components/trafikverket_camera/test_binary_sensor.py diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py new file mode 100644 index 00000000000..bfbecf707bf --- /dev/null +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -0,0 +1,97 @@ +"""Binary sensor platform for Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CameraData, TVDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass +class DeviceBaseEntityDescriptionMixin: + """Mixin for required Trafikverket Camera base description keys.""" + + value_fn: Callable[[CameraData], bool | None] + + +@dataclass +class TVCameraSensorEntityDescription( + BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin +): + """Describes Trafikverket Camera binary sensor entity.""" + + +BINARY_SENSOR_TYPE = TVCameraSensorEntityDescription( + key="active", + translation_key="active", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.active, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Trafikverket Camera binary sensor platform.""" + + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + TrafikverketCameraBinarySensor( + coordinator, entry.entry_id, entry.title, BINARY_SENSOR_TYPE + ) + ] + ) + + +class TrafikverketCameraBinarySensor( + CoordinatorEntity[TVDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Trafikverket Camera binary sensor.""" + + entity_description: TVCameraSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + name: str, + entity_description: TVCameraSensorEntityDescription, + ) -> None: + """Initiate Trafikverket Camera Binary sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Trafikverket", + model="v1.0", + name=name, + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + self._update_attr() + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + self._attr_is_on = self.entity_description.value_fn(self.coordinator.data) + + @callback + def _handle_coordinator_update(self) -> None: + self._update_attr() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py index 388df241d99..ff40d1bbc91 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform DOMAIN = "trafikverket_camera" CONF_LOCATION = "location" -PLATFORMS = [Platform.CAMERA, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index 27360100a29..651225934cd 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -47,6 +47,11 @@ } } }, + "binary_sensor": { + "active": { + "name": "Active" + } + }, "sensor": { "direction": { "name": "Direction" diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py new file mode 100644 index 00000000000..6f7eb540289 --- /dev/null +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -0,0 +1,20 @@ +"""The test for the Trafikverket binary sensor platform.""" +from __future__ import annotations + +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_camera: CameraInfo, +) -> None: + """Test the Trafikverket Camera binary sensor.""" + + state = hass.states.get("binary_sensor.test_location_active") + assert state.state == STATE_ON diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index 5dff358d974..b9add7ae483 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -38,7 +38,7 @@ async def test_exclude_attributes( None, hass.states.async_entity_ids(), ) - assert len(states) == 7 + assert len(states) == 8 assert states.get("camera.test_location") for entity_states in states.values(): for state in entity_states: From 73a695d857fcfbaa90587610896b8e89e3d99bbf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Sep 2023 01:22:33 +0200 Subject: [PATCH 366/984] Fix incorrect docstring in TV Camera sensor test (#100083) --- tests/components/trafikverket_camera/test_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py index cc97f4dbdcb..581fed1d289 100644 --- a/tests/components/trafikverket_camera/test_sensor.py +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -1,4 +1,4 @@ -"""The test for the sensibo select platform.""" +"""The test for the Trafikverket sensor platform.""" from __future__ import annotations from pytrafikverket.trafikverket_camera import CameraInfo From 6c45f43c5d2f69c535b2da7108e3cd484e628def Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Mon, 11 Sep 2023 01:24:57 +0200 Subject: [PATCH 367/984] Renson number entity (#99358) * Starting number sensor * Filter change config * Add translation to number entity * add number entity to .coveragerc * Moved has_entity_name to description + changed name of entity * Add self.coordinator.async_request_refresh() after changing value * Add device calss and unit of measurement to number entity --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 1 + homeassistant/components/renson/number.py | 84 ++++++++++++++++++++ homeassistant/components/renson/strings.json | 5 ++ 4 files changed, 91 insertions(+) create mode 100644 homeassistant/components/renson/number.py diff --git a/.coveragerc b/.coveragerc index 23236891807..e932be670cb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1011,6 +1011,7 @@ omit = homeassistant/components/renson/sensor.py homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py + homeassistant/components/renson/number.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index dbc0468a11a..7ce143d8a21 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -22,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.FAN, + Platform.NUMBER, Platform.SENSOR, ] diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py new file mode 100644 index 00000000000..bf33b75c9e3 --- /dev/null +++ b/homeassistant/components/renson/number.py @@ -0,0 +1,84 @@ +"""Platform to control a Renson ventilation unit.""" +from __future__ import annotations + +import logging + +from renson_endura_delta.field_enum import FILTER_PRESET_FIELD, DataType +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator +from .const import DOMAIN +from .entity import RensonEntity + +_LOGGER = logging.getLogger(__name__) + + +RENSON_NUMBER_DESCRIPTION = NumberEntityDescription( + key="filter_change", + translation_key="filter_change", + icon="mdi:filter", + native_step=1, + native_min_value=0, + native_max_value=360, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson number platform.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + async_add_entities([RensonNumber(RENSON_NUMBER_DESCRIPTION, api, coordinator)]) + + +class RensonNumber(RensonEntity, NumberEntity): + """Representation of the Renson number platform.""" + + def __init__( + self, + description: NumberEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize the Renson number.""" + super().__init__(description.key, api, coordinator) + + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self.api.parse_value( + self.api.get_field_value(self.coordinator.data, FILTER_PRESET_FIELD.name), + DataType.NUMERIC, + ) + + super()._handle_coordinator_update() + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + + await self.hass.async_add_executor_job(self.api.set_filter_days, value) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 20db9e788b8..1a4829c2da9 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -13,6 +13,11 @@ } }, "entity": { + "number": { + "filter_change": { + "name": "Filter clean/replacement" + } + }, "binary_sensor": { "frost_protection_active": { "name": "Frost protection active" From 8beace265b44afa3475dffe2e29406969d83aacd Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 11 Sep 2023 11:30:25 +1200 Subject: [PATCH 368/984] Add unit tests for sensors Electric Kiwi (#97723) * add unit tests for sensors * newline long strings * unit test check and move time * rename entry to entity Co-authored-by: Joost Lekkerkerker * add types to test Co-authored-by: Joost Lekkerkerker * fix newlined f strings * remove if statement * add some more explaination * Update datetime Co-authored-by: Robert Resch * Simpler time update Co-authored-by: Robert Resch * add missing datetime import * Update docustring - grammar Co-authored-by: Martin Hjelmare * address comments and issues raised * address docstrings too long * Fix docstring --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Robert Resch Co-authored-by: Martin Hjelmare --- .coveragerc | 1 - .../components/electric_kiwi/select.py | 10 +- .../components/electric_kiwi/sensor.py | 9 +- tests/components/electric_kiwi/conftest.py | 77 +++++- .../electric_kiwi/fixtures/get_hop.json | 16 ++ .../electric_kiwi/fixtures/hop_intervals.json | 249 ++++++++++++++++++ .../electric_kiwi/test_config_flow.py | 18 +- tests/components/electric_kiwi/test_sensor.py | 83 ++++++ 8 files changed, 442 insertions(+), 21 deletions(-) create mode 100644 tests/components/electric_kiwi/fixtures/get_hop.json create mode 100644 tests/components/electric_kiwi/fixtures/hop_intervals.json create mode 100644 tests/components/electric_kiwi/test_sensor.py diff --git a/.coveragerc b/.coveragerc index e932be670cb..4df91b250ed 100644 --- a/.coveragerc +++ b/.coveragerc @@ -277,7 +277,6 @@ omit = homeassistant/components/electric_kiwi/__init__.py homeassistant/components/electric_kiwi/api.py homeassistant/components/electric_kiwi/oauth2.py - homeassistant/components/electric_kiwi/sensor.py homeassistant/components/electric_kiwi/coordinator.py homeassistant/components/electric_kiwi/select.py homeassistant/components/eliqonline/sensor.py diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index 9d883c72d1e..eb8aaac8c2f 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -50,7 +50,10 @@ class ElectricKiwiSelectHOPEntity( ) -> None: """Initialise the HOP selection entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator._ek_api.customer_number}_{coordinator._ek_api.connection_id}_{description.key}" + self._attr_unique_id = ( + f"{coordinator._ek_api.customer_number}" + f"_{coordinator._ek_api.connection_id}_{description.key}" + ) self.entity_description = description self.values_dict = coordinator.get_hop_options() self._attr_options = list(self.values_dict) @@ -58,7 +61,10 @@ class ElectricKiwiSelectHOPEntity( @property def current_option(self) -> str | None: """Return the currently selected option.""" - return f"{self.coordinator.data.start.start_time} - {self.coordinator.data.end.end_time}" + return ( + f"{self.coordinator.data.start.start_time}" + f" - {self.coordinator.data.end.end_time}" + ) async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 8c983b92dd5..8017bbf006e 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -62,7 +62,7 @@ def _check_and_move_time(hop: Hop, time: str) -> datetime: return date_time -HOP_SENSOR_TYPE: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( +HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( ElectricKiwiHOPSensorEntityDescription( key=ATTR_EK_HOP_START, translation_key="hopfreepowerstart", @@ -85,7 +85,7 @@ async def async_setup_entry( hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] hop_entities = [ ElectricKiwiHOPEntity(hop_coordinator, description) - for description in HOP_SENSOR_TYPE + for description in HOP_SENSOR_TYPES ] async_add_entities(hop_entities) @@ -107,7 +107,10 @@ class ElectricKiwiHOPEntity( """Entity object for Electric Kiwi sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator._ek_api.customer_number}_{coordinator._ek_api.connection_id}_{description.key}" + self._attr_unique_id = ( + f"{coordinator._ek_api.customer_number}" + f"_{coordinator._ek_api.connection_id}_{description.key}" + ) self.entity_description = description @property diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 525f5742382..f7e60e975f8 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -1,9 +1,12 @@ """Define fixtures for electric kiwi tests.""" from __future__ import annotations -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator +from time import time from unittest.mock import AsyncMock, patch +import zoneinfo +from electrickiwi_api.model import Hop, HopIntervals import pytest from homeassistant.components.application_credentials import ( @@ -14,12 +17,17 @@ from homeassistant.components.electric_kiwi.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_value_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" +TZ_NAME = "Pacific/Auckland" +TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) +YieldFixture = Generator[AsyncMock, None, None] +ComponentSetup = Callable[[], Awaitable[bool]] + @pytest.fixture(autouse=True) async def request_setup(current_request_with_host) -> None: @@ -28,14 +36,23 @@ async def request_setup(current_request_with_host) -> None: @pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup credentials.""" - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) +def component_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> ComponentSetup: + """Fixture for setting up the integration.""" + + async def _setup_func() -> bool: + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + config_entry.add_to_hass(hass) + return await hass.config_entries.async_setup(config_entry.entry_id) + + return _setup_func @pytest.fixture(name="config_entry") @@ -45,12 +62,18 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Electric Kiwi", domain=DOMAIN, data={ - "id": "mock_user", + "id": "12345", "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, }, unique_id=DOMAIN, ) - entry.add_to_hass(hass) return entry @@ -61,3 +84,33 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.electric_kiwi.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +@pytest.fixture(name="ek_auth") +def electric_kiwi_auth() -> YieldFixture: + """Patch access to electric kiwi access token.""" + with patch( + "homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" + ) as mock_auth: + mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") + yield mock_auth + + +@pytest.fixture(name="ek_api") +def ek_api() -> YieldFixture: + """Mock ek api and return values.""" + with patch( + "homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True + ) as mock_ek_api: + mock_ek_api.return_value.customer_number = 123456 + mock_ek_api.return_value.connection_id = 123456 + mock_ek_api.return_value.set_active_session.return_value = None + mock_ek_api.return_value.get_hop_intervals.return_value = ( + HopIntervals.from_dict( + load_json_value_fixture("hop_intervals.json", DOMAIN) + ) + ) + mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( + load_json_value_fixture("get_hop.json", DOMAIN) + ) + yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/get_hop.json b/tests/components/electric_kiwi/fixtures/get_hop.json new file mode 100644 index 00000000000..d29825391e9 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/get_hop.json @@ -0,0 +1,16 @@ +{ + "data": { + "connection_id": "3", + "customer_number": 1000001, + "end": { + "end_time": "5:00 PM", + "interval": "34" + }, + "start": { + "start_time": "4:00 PM", + "interval": "33" + }, + "type": "hop_customer" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/hop_intervals.json b/tests/components/electric_kiwi/fixtures/hop_intervals.json new file mode 100644 index 00000000000..15ecc174f13 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/hop_intervals.json @@ -0,0 +1,249 @@ +{ + "data": { + "hop_duration": "60", + "type": "hop_intervals", + "intervals": { + "1": { + "active": 1, + "end_time": "1:00 AM", + "start_time": "12:00 AM" + }, + "2": { + "active": 1, + "end_time": "1:30 AM", + "start_time": "12:30 AM" + }, + "3": { + "active": 1, + "end_time": "2:00 AM", + "start_time": "1:00 AM" + }, + "4": { + "active": 1, + "end_time": "2:30 AM", + "start_time": "1:30 AM" + }, + "5": { + "active": 1, + "end_time": "3:00 AM", + "start_time": "2:00 AM" + }, + "6": { + "active": 1, + "end_time": "3:30 AM", + "start_time": "2:30 AM" + }, + "7": { + "active": 1, + "end_time": "4:00 AM", + "start_time": "3:00 AM" + }, + "8": { + "active": 1, + "end_time": "4:30 AM", + "start_time": "3:30 AM" + }, + "9": { + "active": 1, + "end_time": "5:00 AM", + "start_time": "4:00 AM" + }, + "10": { + "active": 1, + "end_time": "5:30 AM", + "start_time": "4:30 AM" + }, + "11": { + "active": 1, + "end_time": "6:00 AM", + "start_time": "5:00 AM" + }, + "12": { + "active": 1, + "end_time": "6:30 AM", + "start_time": "5:30 AM" + }, + "13": { + "active": 1, + "end_time": "7:00 AM", + "start_time": "6:00 AM" + }, + "14": { + "active": 1, + "end_time": "7:30 AM", + "start_time": "6:30 AM" + }, + "15": { + "active": 1, + "end_time": "8:00 AM", + "start_time": "7:00 AM" + }, + "16": { + "active": 1, + "end_time": "8:30 AM", + "start_time": "7:30 AM" + }, + "17": { + "active": 1, + "end_time": "9:00 AM", + "start_time": "8:00 AM" + }, + "18": { + "active": 1, + "end_time": "9:30 AM", + "start_time": "8:30 AM" + }, + "19": { + "active": 1, + "end_time": "10:00 AM", + "start_time": "9:00 AM" + }, + "20": { + "active": 1, + "end_time": "10:30 AM", + "start_time": "9:30 AM" + }, + "21": { + "active": 1, + "end_time": "11:00 AM", + "start_time": "10:00 AM" + }, + "22": { + "active": 1, + "end_time": "11:30 AM", + "start_time": "10:30 AM" + }, + "23": { + "active": 1, + "end_time": "12:00 PM", + "start_time": "11:00 AM" + }, + "24": { + "active": 1, + "end_time": "12:30 PM", + "start_time": "11:30 AM" + }, + "25": { + "active": 1, + "end_time": "1:00 PM", + "start_time": "12:00 PM" + }, + "26": { + "active": 1, + "end_time": "1:30 PM", + "start_time": "12:30 PM" + }, + "27": { + "active": 1, + "end_time": "2:00 PM", + "start_time": "1:00 PM" + }, + "28": { + "active": 1, + "end_time": "2:30 PM", + "start_time": "1:30 PM" + }, + "29": { + "active": 1, + "end_time": "3:00 PM", + "start_time": "2:00 PM" + }, + "30": { + "active": 1, + "end_time": "3:30 PM", + "start_time": "2:30 PM" + }, + "31": { + "active": 1, + "end_time": "4:00 PM", + "start_time": "3:00 PM" + }, + "32": { + "active": 1, + "end_time": "4:30 PM", + "start_time": "3:30 PM" + }, + "33": { + "active": 1, + "end_time": "5:00 PM", + "start_time": "4:00 PM" + }, + "34": { + "active": 1, + "end_time": "5:30 PM", + "start_time": "4:30 PM" + }, + "35": { + "active": 1, + "end_time": "6:00 PM", + "start_time": "5:00 PM" + }, + "36": { + "active": 1, + "end_time": "6:30 PM", + "start_time": "5:30 PM" + }, + "37": { + "active": 1, + "end_time": "7:00 PM", + "start_time": "6:00 PM" + }, + "38": { + "active": 1, + "end_time": "7:30 PM", + "start_time": "6:30 PM" + }, + "39": { + "active": 1, + "end_time": "8:00 PM", + "start_time": "7:00 PM" + }, + "40": { + "active": 1, + "end_time": "8:30 PM", + "start_time": "7:30 PM" + }, + "41": { + "active": 1, + "end_time": "9:00 PM", + "start_time": "8:00 PM" + }, + "42": { + "active": 1, + "end_time": "9:30 PM", + "start_time": "8:30 PM" + }, + "43": { + "active": 1, + "end_time": "10:00 PM", + "start_time": "9:00 PM" + }, + "44": { + "active": 1, + "end_time": "10:30 PM", + "start_time": "9:30 PM" + }, + "45": { + "active": 1, + "end_time": "11:00 AM", + "start_time": "10:00 PM" + }, + "46": { + "active": 1, + "end_time": "11:30 PM", + "start_time": "10:30 PM" + }, + "47": { + "active": 1, + "end_time": "12:00 AM", + "start_time": "11:00 PM" + }, + "48": { + "active": 1, + "end_time": "12:30 AM", + "start_time": "11:30 PM" + } + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index 51d00722341..1199c3e555a 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -21,6 +21,7 @@ from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI @@ -31,6 +32,17 @@ from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.usefixtures("mock_setup_entry") +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup application credentials component.""" + await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: """Test config flow base case with no credentials registered.""" result = await hass.config_entries.flow.async_init( @@ -45,12 +57,12 @@ async def test_full_flow( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, current_request_with_host: None, - setup_credentials, + setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: """Check full flow.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) result = await hass.config_entries.flow.async_init( @@ -103,7 +115,7 @@ async def test_existing_entry( config_entry: MockConfigEntry, ) -> None: """Check existing entry.""" - + config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await hass.config_entries.flow.async_init( diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py new file mode 100644 index 00000000000..ef268735334 --- /dev/null +++ b/tests/components/electric_kiwi/test_sensor.py @@ -0,0 +1,83 @@ +"""The tests for Electric Kiwi sensors.""" + + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +from freezegun import freeze_time +import pytest + +from homeassistant.components.electric_kiwi.const import ATTRIBUTION +from homeassistant.components.electric_kiwi.sensor import _check_and_move_time +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry +import homeassistant.util.dt as dt_util + +from .conftest import TIMEZONE, ComponentSetup, YieldFixture + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("sensor", "sensor_state"), + [ + ("sensor.hour_of_free_power_start", "4:00 PM"), + ("sensor.hour_of_free_power_end", "5:00 PM"), + ], +) +async def test_hop_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + ek_api: YieldFixture, + ek_auth: YieldFixture, + entity_registry: EntityRegistry, + component_setup: ComponentSetup, + sensor: str, + sensor_state: str, +) -> None: + """Test HOP sensors for the Electric Kiwi integration. + + This time (note no day is given, it's only a time) is fed + from the Electric Kiwi API. if the API returns 4:00 PM, the + sensor state should be set to today at 4pm or if now is past 4pm, + then tomorrow at 4pm. + """ + assert await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + + entity = entity_registry.async_get(sensor) + assert entity + + state = hass.states.get(sensor) + assert state + + api = ek_api(Mock()) + hop_data = await api.get_hop() + + value = _check_and_move_time(hop_data, sensor_state) + + value = value.astimezone(UTC) + assert state.state == value.isoformat(timespec="seconds") + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + + +async def test_check_and_move_time(ek_api: AsyncMock) -> None: + """Test correct time is returned depending on time of day.""" + hop = await ek_api(Mock()).get_hop() + + test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TIMEZONE) + dt_util.set_default_time_zone(TIMEZONE) + + with freeze_time(test_time): + value = _check_and_move_time(hop, "4:00 PM") + assert str(value) == "2023-06-22 16:00:00+12:00" + + test_time = test_time.replace(hour=10) + + with freeze_time(test_time): + value = _check_and_move_time(hop, "4:00 PM") + assert str(value) == "2023-06-21 16:00:00+12:00" From 0fb678abfc35fa07f7b242423414bfb57e7c8c75 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 11 Sep 2023 08:49:10 +0200 Subject: [PATCH 369/984] Remove Comelit alarm data retrieval (#100067) fix: remove alarm data retrieval --- homeassistant/components/comelit/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index beb7266c403..1affd5046fe 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -44,7 +44,6 @@ class ComelitSerialBridge(DataUpdateCoordinator): raise ConfigEntryAuthFailed devices_data = await self.api.get_all_devices() - alarm_data = await self.api.get_alarm_config() await self.api.logout() - return devices_data | alarm_data + return devices_data From f121e891fd753a9b134a498fb549371ee9523d86 Mon Sep 17 00:00:00 2001 From: Greig Sheridan Date: Mon, 11 Sep 2023 19:16:21 +1200 Subject: [PATCH 370/984] Remove duplicated word in enphase description text (#100098) --- homeassistant/components/enphase_envoy/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index ae0ac31413c..92eca38ef20 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -3,7 +3,7 @@ "flow_title": "{serial} ({host})", "step": { "user": { - "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models models, enter username `installer` without a password.", + "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models, enter username `installer` without a password.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", From 43fe8d16c30199eae54e8fb329b284268a700346 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 09:32:43 +0200 Subject: [PATCH 371/984] Use shorthand attributes in ZAMG (#99925) Co-authored-by: Robert Resch --- homeassistant/components/zamg/weather.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index ff98496bd40..98e08106dca 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -32,6 +32,10 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.HPA + _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS def __init__( self, coordinator: ZamgDataUpdateCoordinator, name: str, station_id: str @@ -48,16 +52,6 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): configuration_url=MANUFACTURER_URL, name=coordinator.name, ) - # set units of ZAMG API - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - - @property - def condition(self) -> str | None: - """Return the current condition.""" - return None @property def native_temperature(self) -> float | None: From eb0099dee80971cb4619852d5e03964795f18b9e Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 11 Sep 2023 10:36:55 +0300 Subject: [PATCH 372/984] Move smtp constants to const.py (#99542) --- homeassistant/components/smtp/__init__.py | 5 ---- homeassistant/components/smtp/const.py | 22 ++++++++++++++ homeassistant/components/smtp/notify.py | 35 ++++++++++++----------- tests/components/smtp/test_notify.py | 2 +- 4 files changed, 41 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/smtp/const.py diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index abf54efdd9d..5e7fb41c212 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -1,6 +1 @@ """The smtp component.""" - -from homeassistant.const import Platform - -DOMAIN = "smtp" -PLATFORMS = [Platform.NOTIFY] diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py new file mode 100644 index 00000000000..1fa077a24fb --- /dev/null +++ b/homeassistant/components/smtp/const.py @@ -0,0 +1,22 @@ +"""Constants for the smtp integration.""" + +from typing import Final + +DOMAIN: Final = "smtp" + +ATTR_IMAGES: Final = "images" # optional embedded image file attachments +ATTR_HTML: Final = "html" +ATTR_SENDER_NAME: Final = "sender_name" + +CONF_ENCRYPTION: Final = "encryption" +CONF_DEBUG: Final = "debug" +CONF_SERVER: Final = "server" +CONF_SENDER_NAME: Final = "sender_name" + +DEFAULT_HOST: Final = "localhost" +DEFAULT_PORT: Final = 587 +DEFAULT_TIMEOUT: Final = 5 +DEFAULT_DEBUG: Final = False +DEFAULT_ENCRYPTION: Final = "starttls" + +ENCRYPTION_OPTIONS: Final = ["tls", "starttls", "none"] diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 7037c239db3..6836a0b9f6b 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -36,26 +37,26 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util from homeassistant.util.ssl import client_context -from . import DOMAIN, PLATFORMS +from .const import ( + ATTR_HTML, + ATTR_IMAGES, + CONF_DEBUG, + CONF_ENCRYPTION, + CONF_SENDER_NAME, + CONF_SERVER, + DEFAULT_DEBUG, + DEFAULT_ENCRYPTION, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_TIMEOUT, + DOMAIN, + ENCRYPTION_OPTIONS, +) + +PLATFORMS = [Platform.NOTIFY] _LOGGER = logging.getLogger(__name__) -ATTR_IMAGES = "images" # optional embedded image file attachments -ATTR_HTML = "html" - -CONF_ENCRYPTION = "encryption" -CONF_DEBUG = "debug" -CONF_SERVER = "server" -CONF_SENDER_NAME = "sender_name" - -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 587 -DEFAULT_TIMEOUT = 5 -DEFAULT_DEBUG = False -DEFAULT_ENCRYPTION = "starttls" - -ENCRYPTION_OPTIONS = ["tls", "starttls", "none"] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]), diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index aca30c8eac7..86a21c754ed 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -6,7 +6,7 @@ import pytest from homeassistant import config as hass_config import homeassistant.components.notify as notify -from homeassistant.components.smtp import DOMAIN +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 20d0ebe3fab9528cb9806ec9821447f560c1e5e9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 11 Sep 2023 10:58:33 +0200 Subject: [PATCH 373/984] Add TYPE_CHECKING condition on type assertions for mqtt (#100107) Add TYPE_CHECKING condition on type assertions --- homeassistant/components/mqtt/__init__.py | 5 +++-- homeassistant/components/mqtt/camera.py | 4 +++- homeassistant/components/mqtt/config_flow.py | 8 +++++--- homeassistant/components/mqtt/debug_info.py | 12 ++++++------ homeassistant/components/mqtt/device_trigger.py | 8 +++++--- homeassistant/components/mqtt/diagnostics.py | 5 +++-- homeassistant/components/mqtt/discovery.py | 5 +++-- homeassistant/components/mqtt/image.py | 5 +++-- homeassistant/components/mqtt/mixins.py | 8 +++++--- homeassistant/components/mqtt/subscription.py | 5 +++-- 10 files changed, 39 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 50ab9dec36f..5b5c39e6831 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Callable from datetime import datetime import logging -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast import jinja2 import voluptuous as vol @@ -313,7 +313,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return - assert msg_topic is not None + if TYPE_CHECKING: + assert msg_topic is not None await mqtt_data.client.async_publish(msg_topic, payload, qos, retain) hass.services.async_register( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 166bfdd38cc..edddd0f2239 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -4,6 +4,7 @@ from __future__ import annotations from base64 import b64decode import functools import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -112,7 +113,8 @@ class MqttCamera(MqttEntity, Camera): if CONF_IMAGE_ENCODING in self._config: self._last_image = b64decode(msg.payload) else: - assert isinstance(msg.payload, bytes) + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) self._last_image = msg.payload self._sub_state = subscription.async_prepare_subscribe_topics( diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 9f960b0d909..4f46dffec11 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -6,7 +6,7 @@ from collections.abc import Callable import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType -from typing import Any +from typing import TYPE_CHECKING, Any from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import load_pem_x509_certificate @@ -224,7 +224,8 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Confirm a Hass.io discovery.""" errors: dict[str, str] = {} - assert self._hassio_discovery + if TYPE_CHECKING: + assert self._hassio_discovery if user_input is not None: data: dict[str, Any] = self._hassio_discovery.copy() @@ -312,7 +313,8 @@ class MQTTOptionsFlowHandler(OptionsFlow): def _birth_will(birt_or_will: str) -> dict[str, Any]: """Return the user input for birth or will.""" - assert user_input + if TYPE_CHECKING: + assert user_input return { ATTR_TOPIC: user_input[f"{birt_or_will}_topic"], ATTR_PAYLOAD: user_input.get(f"{birt_or_will}_payload", ""), diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index bdbdd74de96..6b4b90586a7 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -5,7 +5,7 @@ from collections import deque from collections.abc import Callable import datetime as dt from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any import attr @@ -128,11 +128,11 @@ def update_entity_discovery_data( hass: HomeAssistant, discovery_payload: DiscoveryInfoType, entity_id: str ) -> None: """Update discovery data.""" - assert ( - discovery_data := get_mqtt_data(hass).debug_info_entities[entity_id][ - "discovery_data" - ] - ) is not None + discovery_data = get_mqtt_data(hass).debug_info_entities[entity_id][ + "discovery_data" + ] + if TYPE_CHECKING: + assert discovery_data is not None discovery_data[ATTR_DISCOVERY_PAYLOAD] = discovery_payload diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 36291ae0be8..fc7528743fa 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import Any +from typing import TYPE_CHECKING, Any import attr import voluptuous as vol @@ -269,7 +269,8 @@ async def async_setup_trigger( config = TRIGGER_DISCOVERY_SCHEMA(config) device_id = update_device(hass, config_entry, config) - assert isinstance(device_id, str) + if TYPE_CHECKING: + assert isinstance(device_id, str) mqtt_device_trigger = MqttDeviceTrigger( hass, config, device_id, discovery_data, config_entry ) @@ -286,7 +287,8 @@ async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None if device_trigger: device_trigger.detach_trigger() discovery_data = device_trigger.discovery_data - assert discovery_data is not None + if TYPE_CHECKING: + assert discovery_data is not None discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] debug_info.remove_trigger_discovery_data(hass, discovery_hash) diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 173c583ca6a..82bae04d2c9 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -1,7 +1,7 @@ """Diagnostics support for MQTT.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data @@ -45,7 +45,8 @@ def _async_get_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" mqtt_instance = get_mqtt_data(hass).client - assert mqtt_instance is not None + if TYPE_CHECKING: + assert mqtt_instance is not None redacted_config = async_redact_data(mqtt_instance.conf, REDACT_CONFIG) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index b05e57280f3..c78319bb46a 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -7,7 +7,7 @@ import functools import logging import re import time -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -343,7 +343,8 @@ async def async_start( # noqa: C901 integration: str, msg: ReceiveMessage ) -> None: """Process the received message.""" - assert mqtt_data.data_config_flow_lock + if TYPE_CHECKING: + assert mqtt_data.data_config_flow_lock key = f"{integration}_{msg.subscribed_topic}" # Lock to prevent initiating many parallel config flows. diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index da62416d29e..da526575a77 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -6,7 +6,7 @@ import binascii from collections.abc import Callable import functools import logging -from typing import Any +from typing import TYPE_CHECKING, Any import httpx import voluptuous as vol @@ -172,7 +172,8 @@ class MqttImage(MqttEntity, ImageEntity): if CONF_IMAGE_ENCODING in self._config: self._last_image = b64decode(msg.payload) else: - assert isinstance(msg.payload, bytes) + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) self._last_image = msg.payload except (binascii.Error, ValueError, AssertionError) as err: _LOGGER.error( diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 3b28bc8804f..ceccfa5adc8 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Callable, Coroutine from functools import partial import logging -from typing import Any, Protocol, cast, final +from typing import TYPE_CHECKING, Any, Protocol, cast, final import voluptuous as vol @@ -850,7 +850,8 @@ class MqttDiscoveryUpdate(Entity): discovery_hash, payload, ) - assert self._discovery_data + if TYPE_CHECKING: + assert self._discovery_data old_payload: DiscoveryInfoType old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) @@ -877,7 +878,8 @@ class MqttDiscoveryUpdate(Entity): send_discovery_done(self.hass, self._discovery_data) if discovery_hash: - assert self._discovery_data is not None + if TYPE_CHECKING: + assert self._discovery_data is not None debug_info.add_entity_discovery_data( self.hass, self._discovery_data, self.entity_id ) diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index dda80bba84e..3f8f0f4ee3e 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from typing import Any +from typing import TYPE_CHECKING, Any import attr @@ -31,7 +31,8 @@ class EntitySubscription: ) -> None: """Re-subscribe to the new topic if necessary.""" if not self._should_resubscribe(other): - assert other + if TYPE_CHECKING: + assert other self.unsubscribe_callback = other.unsubscribe_callback return From a4cb06d09f28b2e6874c3cd909a8279bab2a1587 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Sep 2023 11:00:50 +0200 Subject: [PATCH 374/984] Also handle DiscovergyClientError as UpdateFailed (#100038) * Also handle DiscovergyClientError as UpdateFailed * Change AccessTokenExpired to InvalidLogin * Also add DiscovergyClientError to config flow and tests --- .../components/discovergy/config_flow.py | 2 +- .../components/discovergy/coordinator.py | 6 +++--- .../components/discovergy/test_config_flow.py | 21 ++++++++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index 3434b1dd84c..e035661db10 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -85,7 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): httpx_client=get_async_client(self.hass), authentication=BasicAuth(), ).meters() - except discovergyError.HTTPError: + except (discovergyError.HTTPError, discovergyError.DiscovergyClientError): errors["base"] = "cannot_connect" except discovergyError.InvalidLogin: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 1371b1f26ac..5f27c6a43d2 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from pydiscovergy import Discovergy -from pydiscovergy.error import AccessTokenExpired, HTTPError +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin from pydiscovergy.models import Meter, Reading from homeassistant.core import HomeAssistant @@ -44,11 +44,11 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): """Get last reading for meter.""" try: return await self.discovergy_client.meter_last_reading(self.meter.meter_id) - except AccessTokenExpired as err: + except InvalidLogin as err: raise ConfigEntryAuthFailed( f"Auth expired while fetching last reading for meter {self.meter.meter_id}" ) from err - except HTTPError as err: + except (HTTPError, DiscovergyClientError) as err: raise UpdateFailed( f"Error while fetching last reading for meter {self.meter.meter_id}" ) from err diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index bc4fd2d9e9d..ad9fde46b64 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Discovergy config flow.""" from unittest.mock import Mock, patch -from pydiscovergy.error import HTTPError, InvalidLogin +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin from homeassistant import data_entry_flow from homeassistant.components.discovergy.const import DOMAIN @@ -114,6 +114,25 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_form_client_error(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("pydiscovergy.Discovergy.meters", side_effect=DiscovergyClientError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + async def test_form_unknown_exception(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( From 10bb8f5396b3bfb8192080e67d2bb25f54c858ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiit=20R=C3=A4tsep?= Date: Mon, 11 Sep 2023 12:15:46 +0300 Subject: [PATCH 375/984] Fix Soma cover tilt (#99717) --- homeassistant/components/soma/cover.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 26487756a44..4aa2559b140 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -51,6 +51,8 @@ class SomaTilt(SomaEntity, CoverEntity): | CoverEntityFeature.STOP_TILT | CoverEntityFeature.SET_TILT_POSITION ) + CLOSED_UP_THRESHOLD = 80 + CLOSED_DOWN_THRESHOLD = 20 @property def current_cover_tilt_position(self) -> int: @@ -60,7 +62,12 @@ class SomaTilt(SomaEntity, CoverEntity): @property def is_closed(self) -> bool: """Return if the cover tilt is closed.""" - return self.current_position == 0 + if ( + self.current_position < self.CLOSED_DOWN_THRESHOLD + or self.current_position > self.CLOSED_UP_THRESHOLD + ): + return True + return False def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" From 58072189fc158554b2ddbeb3bcbcf205786754a7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 11 Sep 2023 12:14:50 +0200 Subject: [PATCH 376/984] Update black to 23.9.1 (#100108) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50829592f53..1a38238e159 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: args: - --fix - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.9.0 + rev: 23.9.1 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 9663d0a8fb7..98c8f40b82b 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.9.0 +black==23.9.1 codespell==2.2.2 ruff==0.0.285 yamllint==1.32.0 From 5781e5e03e9e86a8de602352de185831cfc40a4d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 12:36:37 +0200 Subject: [PATCH 377/984] Use json to store Withings test data fixtures (#99998) * Decouple Withings sensor tests from yaml * Improve Withings config flow tests * Improve Withings config flow tests * Fix feedback * Use fixtures to store Withings testdata structures * Use fixtures to store Withings testdata structures * Use JSON * Fix * Use load_json_object_fixture --- tests/components/withings/__init__.py | 56 ++-- tests/components/withings/conftest.py | 246 +--------------- .../withings/fixtures/person0_get_device.json | 18 ++ .../withings/fixtures/person0_get_meas.json | 278 ++++++++++++++++++ .../withings/fixtures/person0_get_sleep.json | 60 ++++ .../fixtures/person0_notify_list.json | 3 + tests/components/withings/test_sensor.py | 78 ++--- 7 files changed, 438 insertions(+), 301 deletions(-) create mode 100644 tests/components/withings/fixtures/person0_get_device.json create mode 100644 tests/components/withings/fixtures/person0_get_meas.json create mode 100644 tests/components/withings/fixtures/person0_get_sleep.json create mode 100644 tests/components/withings/fixtures/person0_notify_list.json diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index e148c1a2c84..b87188f3022 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1,6 +1,6 @@ """Tests for the withings component.""" from collections.abc import Iterable -from typing import Any, Optional +from typing import Any from urllib.parse import urlparse import arrow @@ -10,6 +10,8 @@ from withings_api.common import ( MeasureGetMeasGroupCategory, MeasureGetMeasResponse, MeasureType, + NotifyAppli, + NotifyListResponse, SleepGetSummaryResponse, UserGetDeviceResponse, ) @@ -17,7 +19,9 @@ from withings_api.common import ( from homeassistant.components.webhook import async_generate_url from homeassistant.core import HomeAssistant -from .common import ProfileConfig, WebhookResponse +from .common import WebhookResponse + +from tests.common import load_json_object_fixture async def call_webhook( @@ -43,19 +47,23 @@ async def call_webhook( class MockWithings: """Mock object for Withings.""" - def __init__(self, user_profile: ProfileConfig): + def __init__( + self, + device_fixture: str = "person0_get_device.json", + measurement_fixture: str = "person0_get_meas.json", + sleep_fixture: str = "person0_get_sleep.json", + notify_list_fixture: str = "person0_notify_list.json", + ): """Initialize mock.""" - self.api_response_user_get_device = user_profile.api_response_user_get_device - self.api_response_measure_get_meas = user_profile.api_response_measure_get_meas - self.api_response_sleep_get_summary = ( - user_profile.api_response_sleep_get_summary - ) + self.device_fixture = device_fixture + self.measurement_fixture = measurement_fixture + self.sleep_fixture = sleep_fixture + self.notify_list_fixture = notify_list_fixture def user_get_device(self) -> UserGetDeviceResponse: """Get devices.""" - if isinstance(self.api_response_user_get_device, Exception): - raise self.api_response_user_get_device - return self.api_response_user_get_device + fixture = load_json_object_fixture(f"withings/{self.device_fixture}") + return UserGetDeviceResponse(**fixture) def measure_get_meas( self, @@ -67,19 +75,25 @@ class MockWithings: lastupdate: DateType | None = None, ) -> MeasureGetMeasResponse: """Get measurements.""" - if isinstance(self.api_response_measure_get_meas, Exception): - raise self.api_response_measure_get_meas - return self.api_response_measure_get_meas + fixture = load_json_object_fixture(f"withings/{self.measurement_fixture}") + return MeasureGetMeasResponse(**fixture) def sleep_get_summary( self, data_fields: Iterable[GetSleepSummaryField], - startdateymd: Optional[DateType] = arrow.utcnow(), - enddateymd: Optional[DateType] = arrow.utcnow(), - offset: Optional[int] = None, - lastupdate: Optional[DateType] = arrow.utcnow(), + startdateymd: DateType | None = arrow.utcnow(), + enddateymd: DateType | None = arrow.utcnow(), + offset: int | None = None, + lastupdate: DateType | None = arrow.utcnow(), ) -> SleepGetSummaryResponse: """Get sleep.""" - if isinstance(self.api_response_sleep_get_summary, Exception): - raise self.api_response_sleep_get_summary - return self.api_response_sleep_get_summary + fixture = load_json_object_fixture(f"withings/{self.sleep_fixture}") + return SleepGetSummaryResponse(**fixture) + + def notify_list( + self, + appli: NotifyAppli | None = None, + ) -> NotifyListResponse: + """Get sleep.""" + fixture = load_json_object_fixture(f"withings/{self.notify_list_fixture}") + return NotifyListResponse(**fixture) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 510fc980dc7..8a85b523769 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -4,20 +4,7 @@ import time from typing import Any from unittest.mock import patch -import arrow import pytest -from withings_api.common import ( - GetSleepSummaryData, - GetSleepSummarySerie, - MeasureGetMeasGroup, - MeasureGetMeasGroupAttrib, - MeasureGetMeasGroupCategory, - MeasureGetMeasMeasure, - MeasureGetMeasResponse, - MeasureType, - SleepGetSummaryResponse, - SleepModel, -) from homeassistant.components.application_credentials import ( ClientCredential, @@ -27,10 +14,9 @@ from homeassistant.components.withings.const import DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import MockWithings -from .common import ComponentFactory, new_profile_config +from .common import ComponentFactory from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -46,231 +32,9 @@ SCOPES = [ "user.sleepevents", ] TITLE = "henk" +USER_ID = 12345 WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" -PERSON0 = new_profile_config( - profile="12345", - user_id=12345, - api_response_measure_get_meas=MeasureGetMeasResponse( - measuregrps=( - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-1), - date=arrow.utcnow().shift(hours=-1), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=60 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=50 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=60 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=95 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-2), - date=arrow.utcnow().shift(hours=-2), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=51 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=61 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=21), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=96), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=101 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow(), - date=arrow.utcnow(), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=34 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102 - ), - ), - ), - ), - more=False, - timezone=dt_util.UTC, - updatetime=arrow.get("2019-08-01"), - offset=0, - ), - api_response_sleep_get_summary=SleepGetSummaryResponse( - more=False, - offset=0, - series=( - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - breathing_disturbances_intensity=110, - deepsleepduration=111, - durationtosleep=112, - durationtowakeup=113, - hr_average=114, - hr_max=115, - hr_min=116, - lightsleepduration=117, - remsleepduration=118, - rr_average=119, - rr_max=120, - rr_min=121, - sleep_score=122, - snoring=123, - snoringepisodecount=124, - wakeupcount=125, - wakeupduration=126, - ), - ), - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - breathing_disturbances_intensity=210, - deepsleepduration=211, - durationtosleep=212, - durationtowakeup=213, - hr_average=214, - hr_max=215, - hr_min=216, - lightsleepduration=217, - remsleepduration=218, - rr_average=219, - rr_max=220, - rr_min=221, - sleep_score=222, - snoring=223, - snoringepisodecount=224, - wakeupcount=225, - wakeupduration=226, - ), - ), - ), - ), -) - @pytest.fixture def component_factory( @@ -318,12 +82,12 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title=TITLE, - unique_id="12345", + unique_id=str(USER_ID), data={ "auth_implementation": DOMAIN, "token": { "status": 0, - "userid": "12345", + "userid": str(USER_ID), "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_at": expires_at, @@ -356,7 +120,7 @@ async def mock_setup_integration( ) async def func() -> MockWithings: - mock = MockWithings(PERSON0) + mock = MockWithings() with patch( "homeassistant.components.withings.common.ConfigEntryWithingsApi", return_value=mock, diff --git a/tests/components/withings/fixtures/person0_get_device.json b/tests/components/withings/fixtures/person0_get_device.json new file mode 100644 index 00000000000..8b5e2686686 --- /dev/null +++ b/tests/components/withings/fixtures/person0_get_device.json @@ -0,0 +1,18 @@ +{ + "status": 0, + "body": { + "devices": [ + { + "type": "Scale", + "battery": "high", + "model": "Body+", + "model_id": 5, + "timezone": "Europe/Amsterdam", + "first_session_date": null, + "last_session_date": 1693867179, + "deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d", + "hash_deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d" + } + ] + } +} diff --git a/tests/components/withings/fixtures/person0_get_meas.json b/tests/components/withings/fixtures/person0_get_meas.json new file mode 100644 index 00000000000..a7a2c09156c --- /dev/null +++ b/tests/components/withings/fixtures/person0_get_meas.json @@ -0,0 +1,278 @@ +{ + "more": false, + "timezone": "UTC", + "updatetime": 1564617600, + "offset": 0, + "measuregrps": [ + { + "attrib": 0, + "category": 1, + "created": 1564660800, + "date": 1564660800, + "deviceid": "DEV_ID", + "grpid": 1, + "measures": [ + { + "type": 1, + "unit": 0, + "value": 70 + }, + { + "type": 8, + "unit": 0, + "value": 5 + }, + { + "type": 5, + "unit": 0, + "value": 60 + }, + { + "type": 76, + "unit": 0, + "value": 50 + }, + { + "type": 88, + "unit": 0, + "value": 10 + }, + { + "type": 4, + "unit": 0, + "value": 2 + }, + { + "type": 12, + "unit": 0, + "value": 40 + }, + { + "type": 71, + "unit": 0, + "value": 40 + }, + { + "type": 73, + "unit": 0, + "value": 20 + }, + { + "type": 6, + "unit": -3, + "value": 70 + }, + { + "type": 9, + "unit": 0, + "value": 70 + }, + { + "type": 10, + "unit": 0, + "value": 100 + }, + { + "type": 11, + "unit": 0, + "value": 60 + }, + { + "type": 54, + "unit": -2, + "value": 95 + }, + { + "type": 77, + "unit": -2, + "value": 95 + }, + { + "type": 91, + "unit": 0, + "value": 100 + } + ] + }, + { + "attrib": 0, + "category": 1, + "created": 1564657200, + "date": 1564657200, + "deviceid": "DEV_ID", + "grpid": 1, + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 51 + }, + { + "type": 5, + "unit": 0, + "value": 61 + }, + { + "type": 76, + "unit": 0, + "value": 51 + }, + { + "type": 88, + "unit": 0, + "value": 11 + }, + { + "type": 4, + "unit": 0, + "value": 21 + }, + { + "type": 12, + "unit": 0, + "value": 41 + }, + { + "type": 71, + "unit": 0, + "value": 41 + }, + { + "type": 73, + "unit": 0, + "value": 21 + }, + { + "type": 6, + "unit": -3, + "value": 71 + }, + { + "type": 9, + "unit": 0, + "value": 71 + }, + { + "type": 10, + "unit": 0, + "value": 101 + }, + { + "type": 11, + "unit": 0, + "value": 61 + }, + { + "type": 54, + "unit": -2, + "value": 96 + }, + { + "type": 77, + "unit": -2, + "value": 96 + }, + { + "type": 91, + "unit": 0, + "value": 101 + } + ] + }, + { + "attrib": 1, + "category": 1, + "created": 1564664400, + "date": 1564664400, + "deviceid": "DEV_ID", + "grpid": 1, + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 4 + }, + { + "type": 5, + "unit": 0, + "value": 40 + }, + { + "type": 76, + "unit": 0, + "value": 51 + }, + { + "type": 88, + "unit": 0, + "value": 11 + }, + { + "type": 4, + "unit": 0, + "value": 201 + }, + { + "type": 12, + "unit": 0, + "value": 41 + }, + { + "type": 71, + "unit": 0, + "value": 34 + }, + { + "type": 73, + "unit": 0, + "value": 21 + }, + { + "type": 6, + "unit": -3, + "value": 71 + }, + { + "type": 9, + "unit": 0, + "value": 71 + }, + { + "type": 10, + "unit": 0, + "value": 101 + }, + { + "type": 11, + "unit": 0, + "value": 61 + }, + { + "type": 54, + "unit": -2, + "value": 98 + }, + { + "type": 77, + "unit": -2, + "value": 96 + }, + { + "type": 91, + "unit": 0, + "value": 102 + } + ] + } + ] +} diff --git a/tests/components/withings/fixtures/person0_get_sleep.json b/tests/components/withings/fixtures/person0_get_sleep.json new file mode 100644 index 00000000000..fdc0e064709 --- /dev/null +++ b/tests/components/withings/fixtures/person0_get_sleep.json @@ -0,0 +1,60 @@ +{ + "more": false, + "offset": 0, + "series": [ + { + "timezone": "UTC", + "model": 32, + "startdate": 1548979200, + "enddate": 1548979200, + "date": 1548979200, + "modified": 12345, + "data": { + "breathing_disturbances_intensity": 110, + "deepsleepduration": 111, + "durationtosleep": 112, + "durationtowakeup": 113, + "hr_average": 114, + "hr_max": 115, + "hr_min": 116, + "lightsleepduration": 117, + "remsleepduration": 118, + "rr_average": 119, + "rr_max": 120, + "rr_min": 121, + "sleep_score": 122, + "snoring": 123, + "snoringepisodecount": 124, + "wakeupcount": 125, + "wakeupduration": 126 + } + }, + { + "timezone": "UTC", + "model": 32, + "startdate": 1548979200, + "enddate": 1548979200, + "date": 1548979200, + "modified": 12345, + "data": { + "breathing_disturbances_intensity": 210, + "deepsleepduration": 211, + "durationtosleep": 212, + "durationtowakeup": 213, + "hr_average": 214, + "hr_max": 215, + "hr_min": 216, + "lightsleepduration": 217, + "remsleepduration": 218, + "rr_average": 219, + "rr_max": 220, + "rr_min": 221, + "sleep_score": 222, + "snoring": 223, + "snoringepisodecount": 224, + "wakeupcount": 225, + "wakeupduration": 226 + } + } + ] +} diff --git a/tests/components/withings/fixtures/person0_notify_list.json b/tests/components/withings/fixtures/person0_notify_list.json new file mode 100644 index 00000000000..c905c95e4cb --- /dev/null +++ b/tests/components/withings/fixtures/person0_notify_list.json @@ -0,0 +1,3 @@ +{ + "profiles": [] +} diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 07fcb8fedaa..6ab0fc97f4e 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from . import MockWithings, call_webhook from .common import async_get_entity_id -from .conftest import PERSON0, WEBHOOK_ID, ComponentSetup +from .conftest import USER_ID, WEBHOOK_ID, ComponentSetup from tests.typing import ClientSessionGenerator @@ -26,36 +26,36 @@ WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { EXPECTED_DATA = ( - (PERSON0, Measurement.WEIGHT_KG, 70.0), - (PERSON0, Measurement.FAT_MASS_KG, 5.0), - (PERSON0, Measurement.FAT_FREE_MASS_KG, 60.0), - (PERSON0, Measurement.MUSCLE_MASS_KG, 50.0), - (PERSON0, Measurement.BONE_MASS_KG, 10.0), - (PERSON0, Measurement.HEIGHT_M, 2.0), - (PERSON0, Measurement.FAT_RATIO_PCT, 0.07), - (PERSON0, Measurement.DIASTOLIC_MMHG, 70.0), - (PERSON0, Measurement.SYSTOLIC_MMGH, 100.0), - (PERSON0, Measurement.HEART_PULSE_BPM, 60.0), - (PERSON0, Measurement.SPO2_PCT, 0.95), - (PERSON0, Measurement.HYDRATION, 0.95), - (PERSON0, Measurement.PWV, 100.0), - (PERSON0, Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0), - (PERSON0, Measurement.SLEEP_DEEP_DURATION_SECONDS, 322), - (PERSON0, Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0), - (PERSON0, Measurement.SLEEP_HEART_RATE_MAX, 165.0), - (PERSON0, Measurement.SLEEP_HEART_RATE_MIN, 166.0), - (PERSON0, Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334), - (PERSON0, Measurement.SLEEP_REM_DURATION_SECONDS, 336), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0), - (PERSON0, Measurement.SLEEP_SCORE, 222), - (PERSON0, Measurement.SLEEP_SNORING, 173.0), - (PERSON0, Measurement.SLEEP_SNORING_EPISODE_COUNT, 348), - (PERSON0, Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0), - (PERSON0, Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0), - (PERSON0, Measurement.SLEEP_WAKEUP_COUNT, 350), - (PERSON0, Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0), + (Measurement.WEIGHT_KG, 70.0), + (Measurement.FAT_MASS_KG, 5.0), + (Measurement.FAT_FREE_MASS_KG, 60.0), + (Measurement.MUSCLE_MASS_KG, 50.0), + (Measurement.BONE_MASS_KG, 10.0), + (Measurement.HEIGHT_M, 2.0), + (Measurement.FAT_RATIO_PCT, 0.07), + (Measurement.DIASTOLIC_MMHG, 70.0), + (Measurement.SYSTOLIC_MMGH, 100.0), + (Measurement.HEART_PULSE_BPM, 60.0), + (Measurement.SPO2_PCT, 0.95), + (Measurement.HYDRATION, 0.95), + (Measurement.PWV, 100.0), + (Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0), + (Measurement.SLEEP_DEEP_DURATION_SECONDS, 322), + (Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0), + (Measurement.SLEEP_HEART_RATE_MAX, 165.0), + (Measurement.SLEEP_HEART_RATE_MIN, 166.0), + (Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334), + (Measurement.SLEEP_REM_DURATION_SECONDS, 336), + (Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0), + (Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0), + (Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0), + (Measurement.SLEEP_SCORE, 222), + (Measurement.SLEEP_SNORING, 173.0), + (Measurement.SLEEP_SNORING_EPISODE_COUNT, 348), + (Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0), + (Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0), + (Measurement.SLEEP_WAKEUP_COUNT, 350), + (Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0), ) @@ -84,7 +84,7 @@ async def test_sensor_default_enabled_entities( await setup_integration() entity_registry: EntityRegistry = er.async_get(hass) - mock = MockWithings(PERSON0) + mock = MockWithings() with patch( "homeassistant.components.withings.common.ConfigEntryWithingsApi", return_value=mock, @@ -93,31 +93,31 @@ async def test_sensor_default_enabled_entities( # Assert entities should exist. for attribute in SENSORS: entity_id = await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN + hass, attribute, USER_ID, SENSOR_DOMAIN ) assert entity_id assert entity_registry.async_is_registered(entity_id) resp = await call_webhook( hass, WEBHOOK_ID, - {"userid": PERSON0.user_id, "appli": NotifyAppli.SLEEP}, + {"userid": USER_ID, "appli": NotifyAppli.SLEEP}, client, ) assert resp.message_code == 0 resp = await call_webhook( hass, WEBHOOK_ID, - {"userid": PERSON0.user_id, "appli": NotifyAppli.WEIGHT}, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, client, ) assert resp.message_code == 0 assert resp.message_code == 0 - for person, measurement, expected in EXPECTED_DATA: + for measurement, expected in EXPECTED_DATA: attribute = WITHINGS_MEASUREMENTS_MAP[measurement] entity_id = await async_get_entity_id( - hass, attribute, person.user_id, SENSOR_DOMAIN + hass, attribute, USER_ID, SENSOR_DOMAIN ) state_obj = hass.states.get(entity_id) @@ -131,11 +131,11 @@ async def test_all_entities( """Test all entities.""" await setup_integration() - mock = MockWithings(PERSON0) + mock = MockWithings() with patch( "homeassistant.components.withings.common.ConfigEntryWithingsApi", return_value=mock, ): for sensor in SENSORS: - entity_id = await async_get_entity_id(hass, sensor, 12345, SENSOR_DOMAIN) + entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) assert hass.states.get(entity_id) == snapshot From a6e9bf830c63f63dc6cc7f08e09a1e975e3f604c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 13:58:47 +0200 Subject: [PATCH 378/984] Decouple Withings binary sensor test from YAML (#100120) --- .../components/withings/test_binary_sensor.py | 93 +++++++------------ 1 file changed, 34 insertions(+), 59 deletions(-) diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 03d72c45296..e9eebbe3567 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,76 +1,51 @@ """Tests for the Withings component.""" +from unittest.mock import patch + from withings_api.common import NotifyAppli -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.withings.binary_sensor import BINARY_SENSORS -from homeassistant.components.withings.common import WithingsEntityDescription -from homeassistant.components.withings.const import Measurement from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry -from .common import ComponentFactory, async_get_entity_id, new_profile_config +from . import MockWithings, call_webhook +from .conftest import USER_ID, WEBHOOK_ID, ComponentSetup -WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { - attr.measurement: attr for attr in BINARY_SENSORS -} +from tests.typing import ClientSessionGenerator async def test_binary_sensor( hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, + setup_integration: ComponentSetup, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - in_bed_attribute = WITHINGS_MEASUREMENTS_MAP[Measurement.IN_BED] - person0 = new_profile_config("person0", 0) - person1 = new_profile_config("person1", 1) + await setup_integration() + mock = MockWithings() + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + client = await hass_client_no_auth() - entity_registry: EntityRegistry = er.async_get(hass) + entity_id = "binary_sensor.henk_in_bed" - await component_factory.configure_component(profile_configs=(person0, person1)) - assert not await async_get_entity_id( - hass, in_bed_attribute, person0.user_id, BINARY_SENSOR_DOMAIN - ) - assert not await async_get_entity_id( - hass, in_bed_attribute, person1.user_id, BINARY_SENSOR_DOMAIN - ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - # person 0 - await component_factory.setup_profile(person0.user_id) - await component_factory.setup_profile(person1.user_id) + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, + ) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON - entity_id0 = await async_get_entity_id( - hass, in_bed_attribute, person0.user_id, BINARY_SENSOR_DOMAIN - ) - entity_id1 = await async_get_entity_id( - hass, in_bed_attribute, person1.user_id, BINARY_SENSOR_DOMAIN - ) - assert entity_id0 - assert entity_id1 - - assert entity_registry.async_is_registered(entity_id0) - assert hass.states.get(entity_id0).state == STATE_UNAVAILABLE - - resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_IN) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id0).state == STATE_ON - - resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_OUT) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id0).state == STATE_OFF - - # person 1 - assert hass.states.get(entity_id1).state == STATE_UNAVAILABLE - - resp = await component_factory.call_webhook(person1.user_id, NotifyAppli.BED_IN) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id1).state == STATE_ON - - # Unload - await component_factory.unload(person0) - await component_factory.unload(person1) + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, + client, + ) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF From 42046a3ce2634df3841f470df2be6d19415e150e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Sep 2023 14:33:43 +0200 Subject: [PATCH 379/984] Fix TriggerEntity.async_added_to_hass (#100119) --- .../components/template/trigger_entity.py | 3 +- tests/components/template/test_sensor.py | 44 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index ca2f7240086..5f5fbe5b99a 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -23,8 +23,7 @@ class TriggerEntity(TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinato async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" - await TriggerBaseEntity.async_added_to_hass(self) - await CoordinatorEntity.async_added_to_hass(self) # type: ignore[arg-type] + await super().async_added_to_hass() if self.coordinator.data is not None: self._process_data() diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index cf9f3724020..0ca666d22f1 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1,7 +1,7 @@ """The test for the Template sensor platform.""" from asyncio import Event from datetime import timedelta -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -1192,6 +1192,48 @@ async def test_trigger_entity( assert state.context is context +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensors": { + "hello": { + "friendly_name": "Hello Name", + "value_template": "{{ trigger.event.data.beer }}", + "entity_picture_template": "{{ '/local/dogs.png' }}", + "icon_template": "{{ 'mdi:pirate' }}", + "attribute_templates": { + "last": "{{now().strftime('%D %X')}}", + "history_1": "{{this.attributes.last|default('Not yet set')}}", + }, + }, + }, + }, + ], + }, + ], +) +async def test_trigger_entity_runs_once( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity handles a trigger once.""" + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == "2" + assert state.attributes.get("last") == ANY + assert state.attributes.get("history_1") == "Not yet set" + + @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( "config", From a6f325d05a5fbef14e08ff4b16b269dd81471857 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 11 Sep 2023 14:36:01 +0200 Subject: [PATCH 380/984] Cache device trigger info during ZHA startup (#99764) * Do not connect to the radio hardware within `_connect_zigpy_app` * Make `connect_zigpy_app` public * Create radio manager instances from config entries * Cache device triggers on startup * reorg zha init * don't reuse gateway * don't nuke yaml configuration * review comments * Fix existing unit tests * Ensure `app.shutdown` is called, not just `app.disconnect` * Revert creating group entities and device registry entries early * Add unit tests --------- Co-authored-by: David F. Mulcahey --- homeassistant/components/zha/__init__.py | 47 ++++++- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/device.py | 21 +-- homeassistant/components/zha/core/gateway.py | 48 +++---- .../components/zha/device_trigger.py | 91 ++++++------ homeassistant/components/zha/radio_manager.py | 33 +++-- .../homeassistant_hardware/conftest.py | 2 +- .../homeassistant_sky_connect/conftest.py | 2 +- .../homeassistant_sky_connect/test_init.py | 2 +- .../homeassistant_yellow/conftest.py | 2 +- tests/components/zha/conftest.py | 25 +++- tests/components/zha/test_config_flow.py | 7 - tests/components/zha/test_device_trigger.py | 130 ++++++++++++++++-- tests/components/zha/test_init.py | 16 +-- tests/components/zha/test_radio_manager.py | 6 +- 15 files changed, 299 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index f9113ebaa90..662ddd080e0 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,5 +1,6 @@ """Support for Zigbee Home Automation devices.""" import asyncio +import contextlib import copy import logging import os @@ -33,13 +34,16 @@ from .core.const import ( CONF_ZIGPY, DATA_ZHA, DATA_ZHA_CONFIG, + DATA_ZHA_DEVICE_TRIGGER_CACHE, DATA_ZHA_GATEWAY, DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, RadioType, ) +from .core.device import get_device_automation_triggers from .core.discovery import GROUP_PROBE +from .radio_manager import ZhaRadioManager DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) ZHA_CONFIG_SCHEMA = { @@ -134,9 +138,43 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b else: _LOGGER.debug("ZHA storage file does not exist or was already removed") - # Re-use the gateway object between ZHA reloads - if (zha_gateway := zha_data.get(DATA_ZHA_GATEWAY)) is None: - zha_gateway = ZHAGateway(hass, config, config_entry) + # Load and cache device trigger information early + zha_data.setdefault(DATA_ZHA_DEVICE_TRIGGER_CACHE, {}) + + device_registry = dr.async_get(hass) + radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) + + async with radio_mgr.connect_zigpy_app() as app: + for dev in app.devices.values(): + dev_entry = device_registry.async_get_device( + identifiers={(DOMAIN, str(dev.ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(dev.ieee))}, + ) + + if dev_entry is None: + continue + + zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE][dev_entry.id] = ( + str(dev.ieee), + get_device_automation_triggers(dev), + ) + + _LOGGER.debug("Trigger cache: %s", zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE]) + + zha_gateway = ZHAGateway(hass, config, config_entry) + + async def async_zha_shutdown(): + """Handle shutdown tasks.""" + await zha_gateway.shutdown() + # clean up any remaining entity metadata + # (entities that have been discovered but not yet added to HA) + # suppress KeyError because we don't know what state we may + # be in when we get here in failure cases + with contextlib.suppress(KeyError): + for platform in PLATFORMS: + del hass.data[DATA_ZHA][platform] + + config_entry.async_on_unload(async_zha_shutdown) try: await zha_gateway.async_initialize() @@ -155,9 +193,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b repairs.async_delete_blocking_issues(hass) - config_entry.async_on_unload(zha_gateway.shutdown) - - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))}, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 63b59e9d8d4..9569fc49659 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -186,6 +186,7 @@ DATA_ZHA = "zha" DATA_ZHA_CONFIG = "config" DATA_ZHA_BRIDGE_ID = "zha_bridge_id" DATA_ZHA_CORE_EVENTS = "zha_core_events" +DATA_ZHA_DEVICE_TRIGGER_CACHE = "zha_device_trigger_cache" DATA_ZHA_GATEWAY = "zha_gateway" DEBUG_COMP_BELLOWS = "bellows" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 1455173b27c..60bf78e516c 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -93,6 +93,16 @@ _UPDATE_ALIVE_INTERVAL = (60, 90) _CHECKIN_GRACE_PERIODS = 2 +def get_device_automation_triggers( + device: zigpy.device.Device, +) -> dict[tuple[str, str], dict[str, str]]: + """Get the supported device automation triggers for a zigpy device.""" + return { + ("device_offline", "device_offline"): {"device_event_type": "device_offline"}, + **getattr(device, "device_automation_triggers", {}), + } + + class DeviceStatus(Enum): """Status of a device.""" @@ -311,16 +321,7 @@ class ZHADevice(LogMixin): @cached_property def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]: """Return the device automation triggers for this device.""" - triggers = { - ("device_offline", "device_offline"): { - "device_event_type": "device_offline" - } - } - - if hasattr(self._zigpy_device, "device_automation_triggers"): - triggers.update(self._zigpy_device.device_automation_triggers) - - return triggers + return get_device_automation_triggers(self._zigpy_device) @property def available_signal(self) -> str: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 353bc6904d7..5cc2cd9a4b9 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -149,12 +149,6 @@ class ZHAGateway: self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] @@ -197,6 +191,12 @@ class ZHAGateway: async def async_initialize(self) -> None: """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + app_controller_cls, app_config = self.get_application_controller_data() self.application_controller = await app_controller_cls.new( config=app_config, @@ -204,23 +204,6 @@ class ZHAGateway: start_radio=False, ) - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - - self.async_load_devices() - - # Groups are attached to the coordinator device so we need to load it early - coordinator = self._find_coordinator_device() - loaded_groups = False - - # We can only load groups early if the coordinator's model info has been stored - # in the zigpy database - if coordinator.model is not None: - self.coordinator_zha_device = self._async_get_or_create_device( - coordinator, restored=True - ) - self.async_load_groups() - loaded_groups = True - for attempt in range(STARTUP_RETRIES): try: await self.application_controller.startup(auto_form=True) @@ -242,14 +225,15 @@ class ZHAGateway: else: break + self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self + self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) + self.coordinator_zha_device = self._async_get_or_create_device( self._find_coordinator_device(), restored=True ) - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) - # If ZHA groups could not load early, we can safely load them now - if not loaded_groups: - self.async_load_groups() + self.async_load_devices() + self.async_load_groups() self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) @@ -766,7 +750,15 @@ class ZHAGateway: unsubscribe() for device in self.devices.values(): device.async_cleanup_handles() - await self.application_controller.shutdown() + # 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() def handle_message( self, diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 9e33e3fa615..7a479443377 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -9,12 +9,12 @@ from homeassistant.components.device_automation.exceptions import ( from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.exceptions import HomeAssistantError, IntegrationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN as ZHA_DOMAIN -from .core.const import ZHA_EVENT +from .core.const import DATA_ZHA, DATA_ZHA_DEVICE_TRIGGER_CACHE, ZHA_EVENT from .core.helpers import async_get_zha_device CONF_SUBTYPE = "subtype" @@ -26,21 +26,32 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) +def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str, dict]: + """Get device trigger data for a device, falling back to the cache if possible.""" + + # First, try checking to see if the device itself is accessible + try: + zha_device = async_get_zha_device(hass, device_id) + except KeyError: + pass + else: + return str(zha_device.ieee), zha_device.device_automation_triggers + + # If not, check the trigger cache but allow any `KeyError`s to propagate + return hass.data[DATA_ZHA][DATA_ZHA_DEVICE_TRIGGER_CACHE][device_id] + + async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" config = TRIGGER_SCHEMA(config) + # Trigger validation will not occur if the config entry is not loaded + _, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID]) + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - try: - zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) - except (KeyError, AttributeError, IntegrationError) as err: - raise InvalidDeviceAutomationConfig from err - if ( - zha_device.device_automation_triggers is None - or trigger not in zha_device.device_automation_triggers - ): + if trigger not in triggers: raise InvalidDeviceAutomationConfig(f"device does not have trigger {trigger}") return config @@ -53,26 +64,26 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE]) + try: - zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) - except (KeyError, AttributeError) as err: + ieee, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID]) + except KeyError as err: raise HomeAssistantError( f"Unable to get zha device {config[CONF_DEVICE_ID]}" ) from err - if trigger_key not in zha_device.device_automation_triggers: + trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if trigger_key not in triggers: raise HomeAssistantError(f"Unable to find trigger {trigger_key}") - trigger = zha_device.device_automation_triggers[trigger_key] - - event_config = { - event_trigger.CONF_PLATFORM: "event", - event_trigger.CONF_EVENT_TYPE: ZHA_EVENT, - event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: str(zha_device.ieee), **trigger}, - } - - event_config = event_trigger.TRIGGER_SCHEMA(event_config) + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: ZHA_EVENT, + event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: ieee, **triggers[trigger_key]}, + } + ) return await event_trigger.async_attach_trigger( hass, event_config, action, trigger_info, platform_type="device" ) @@ -83,24 +94,20 @@ async def async_get_triggers( ) -> list[dict[str, str]]: """List device triggers. - Make sure the device supports device automations and - if it does return the trigger list. + Make sure the device supports device automations and return the trigger list. """ - zha_device = async_get_zha_device(hass, device_id) + try: + _, triggers = _get_device_trigger_data(hass, device_id) + except KeyError as err: + raise InvalidDeviceAutomationConfig from err - if not zha_device.device_automation_triggers: - return [] - - triggers = [] - for trigger, subtype in zha_device.device_automation_triggers: - triggers.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: ZHA_DOMAIN, - CONF_PLATFORM: DEVICE, - CONF_TYPE: trigger, - CONF_SUBTYPE: subtype, - } - ) - - return triggers + return [ + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: ZHA_DOMAIN, + CONF_PLATFORM: DEVICE, + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + for trigger, subtype in triggers + ] diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 751fea99847..df30a85cd7b 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -8,7 +8,7 @@ import copy import enum import logging import os -from typing import Any +from typing import Any, Self from bellows.config import CONF_USE_THREAD import voluptuous as vol @@ -127,8 +127,21 @@ class ZhaRadioManager: self.backups: list[zigpy.backups.NetworkBackup] = [] self.chosen_backup: zigpy.backups.NetworkBackup | None = None + @classmethod + def from_config_entry( + cls, hass: HomeAssistant, config_entry: config_entries.ConfigEntry + ) -> Self: + """Create an instance from a config entry.""" + mgr = cls() + mgr.hass = hass + mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + mgr.device_settings = config_entry.data[CONF_DEVICE] + mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] + + return mgr + @contextlib.asynccontextmanager - async def _connect_zigpy_app(self) -> ControllerApplication: + async def connect_zigpy_app(self) -> ControllerApplication: """Connect to the radio with the current config and then clean up.""" assert self.radio_type is not None @@ -155,10 +168,9 @@ class ZhaRadioManager: ) try: - await app.connect() yield app finally: - await app.disconnect() + await app.shutdown() await asyncio.sleep(CONNECT_DELAY_S) async def restore_backup( @@ -170,7 +182,8 @@ class ZhaRadioManager: ): return - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.backups.restore_backup(backup, **kwargs) @staticmethod @@ -218,7 +231,9 @@ class ZhaRadioManager: """Connect to the radio and load its current network settings.""" backup = None - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() + # Check if the stick has any settings and load them try: await app.load_network_info() @@ -241,12 +256,14 @@ class ZhaRadioManager: async def async_form_network(self) -> None: """Form a brand-new network.""" - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.form_network() async def async_reset_adapter(self) -> None: """Reset the current adapter.""" - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.reset_network_info() async def async_restore_backup_step_1(self) -> bool: diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index 60083c2de94..02b468e558e 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -23,7 +23,7 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 85017866db9..90dbe5af384 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -25,7 +25,7 @@ def mock_zha(): ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index cbf1cfa7d36..3afc8c24774 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -45,7 +45,7 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ): yield diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index e4a666f9f04..a7d66d659f0 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -23,7 +23,7 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 4778f3216da..7d391872a77 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -293,14 +293,20 @@ def zigpy_device_mock(zigpy_app_controller): return _mock_dev +@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True)) @pytest.fixture def zha_device_joined(hass, setup_zha): """Return a newly joined ZHA device.""" + setup_zha_fixture = setup_zha - async def _zha_device(zigpy_dev): + async def _zha_device(zigpy_dev, *, setup_zha: bool = True): zigpy_dev.last_seen = time.time() - await setup_zha() + + if setup_zha: + await setup_zha_fixture() + zha_gateway = common.get_zha_gateway(hass) + zha_gateway.application_controller.devices[zigpy_dev.ieee] = zigpy_dev await zha_gateway.async_device_initialized(zigpy_dev) await hass.async_block_till_done() return zha_gateway.get_device(zigpy_dev.ieee) @@ -308,17 +314,21 @@ def zha_device_joined(hass, setup_zha): return _zha_device +@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True)) @pytest.fixture def zha_device_restored(hass, zigpy_app_controller, setup_zha): """Return a restored ZHA device.""" + setup_zha_fixture = setup_zha - async def _zha_device(zigpy_dev, last_seen=None): + async def _zha_device(zigpy_dev, *, last_seen=None, setup_zha: bool = True): zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev if last_seen is not None: zigpy_dev.last_seen = last_seen - await setup_zha() + if setup_zha: + await setup_zha_fixture() + zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] return zha_gateway.get_device(zigpy_dev.ieee) @@ -376,3 +386,10 @@ def hass_disable_services(hass): hass, "services", MagicMock(has_service=MagicMock(return_value=True)) ): yield hass + + +@pytest.fixture(autouse=True) +def speed_up_radio_mgr(): + """Speed up the radio manager connection time by removing delays.""" + with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.00001): + yield diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index d97a0de0d58..981ca2aca38 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -63,13 +63,6 @@ def mock_multipan_platform(): yield -@pytest.fixture(autouse=True) -def reduce_reconnect_timeout(): - """Reduces reconnect timeout to speed up tests.""" - with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.01): - yield - - @pytest.fixture(autouse=True) def mock_app(): """Mock zigpy app interface.""" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 22f62cb977a..491e2d96d4f 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -9,6 +9,9 @@ import zigpy.zcl.clusters.general as general import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,6 +23,7 @@ from .common import async_enable_traffic from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import ( + MockConfigEntry, async_fire_time_changed, async_get_device_automations, async_mock_service, @@ -45,6 +49,16 @@ LONG_PRESS = "remote_button_long_press" LONG_RELEASE = "remote_button_long_release" +SWITCH_SIGNATURE = { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } +} + + @pytest.fixture(autouse=True) def sensor_platforms_only(): """Only set up the sensor platform and required base platforms to speed up tests.""" @@ -72,16 +86,7 @@ def calls(hass): async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored): """IAS device fixture.""" - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [general.Basic.cluster_id], - SIG_EP_OUTPUT: [general.OnOff.cluster_id], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - } - ) + zigpy_device = zigpy_device_mock(SWITCH_SIGNATURE) zha_device = await zha_device_joined_restored(zigpy_device) zha_device.update_available(True) @@ -397,3 +402,108 @@ async def test_exception_bad_trigger( "Unnamed automation failed to setup triggers and has been disabled: " "device does not have trigger ('junk', 'junk')" in caplog.text ) + + +async def test_validate_trigger_config_missing_info( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + zha_device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device triggers referring to a missing device.""" + + # Join a device + switch = zigpy_device_mock(SWITCH_SIGNATURE) + await zha_device_joined(switch) + + # After we unload the config entry, trigger info was not cached on startup, nor can + # it be pulled from the current device, making it impossible to validate triggers + await hass.config_entries.async_unload(config_entry.entry_id) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", str(switch.ieee))} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "device_id": reg_device.id, + "domain": "zha", + "platform": "device", + "type": "junk", + "subtype": "junk", + }, + "action": { + "service": "test.automation", + "data": {"message": "service called"}, + }, + } + ] + }, + ) + + assert "Unable to get zha device" in caplog.text + + with pytest.raises(InvalidDeviceAutomationConfig): + await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, reg_device.id + ) + + +async def test_validate_trigger_config_unloaded_bad_info( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + zha_device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device triggers referring to a missing device.""" + + # Join a device + switch = zigpy_device_mock(SWITCH_SIGNATURE) + await zha_device_joined(switch) + + # After we unload the config entry, trigger info was not cached on startup, nor can + # it be pulled from the current device, making it impossible to validate triggers + await hass.config_entries.async_unload(config_entry.entry_id) + + # Reload ZHA to persist the device info in the cache + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", str(switch.ieee))} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "device_id": reg_device.id, + "domain": "zha", + "platform": "device", + "type": "junk", + "subtype": "junk", + }, + "action": { + "service": "test.automation", + "data": {"message": "service called"}, + }, + } + ] + }, + ) + + assert "Unable to find trigger" in caplog.text diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 63ca10bbf91..6bac012d667 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -6,7 +6,6 @@ import pytest from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import TransientConnectionError -from homeassistant.components.zha import async_setup_entry from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, CONF_RADIO_TYPE, @@ -22,7 +21,7 @@ from .test_light import LIGHT_ON_OFF from tests.common import MockConfigEntry -DATA_RADIO_TYPE = "deconz" +DATA_RADIO_TYPE = "ezsp" DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0" @@ -137,7 +136,7 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) ) async def test_setup_with_v3_cleaning_uri( - hass: HomeAssistant, path: str, cleaned_path: str + hass: HomeAssistant, path: str, cleaned_path: str, mock_zigpy_connect ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" config_entry_v3 = MockConfigEntry( @@ -150,14 +149,9 @@ async def test_setup_with_v3_cleaning_uri( ) config_entry_v3.add_to_hass(hass) - with patch( - "homeassistant.components.zha.ZHAGateway", return_value=AsyncMock() - ) as mock_gateway: - mock_gateway.return_value.coordinator_ieee = "mock_ieee" - mock_gateway.return_value.radio_description = "mock_radio" - - assert await async_setup_entry(hass, config_entry_v3) - hass.data[DOMAIN]["zha_gateway"] = mock_gateway.return_value + await hass.config_entries.async_setup(config_entry_v3.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry_v3.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 diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 7acf9219d67..1467e2e2951 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -32,9 +32,7 @@ def disable_platform_only(): @pytest.fixture(autouse=True) def reduce_reconnect_timeout(): """Reduces reconnect timeout to speed up tests.""" - with patch( - "homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.0001 - ), patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001): + with patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001): yield @@ -99,7 +97,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock, None, None]: ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ): yield mock_connect_app From 64fde640cab1c69ab7f365a576f79ec8baa89abc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 08:08:19 -0500 Subject: [PATCH 381/984] Bump pyunifiprotect to 4.20.0 (#100092) --- 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 5f2f58ce98a..b63700720e6 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.10.6", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.20.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 77d67f85675..25f365c7825 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2205,7 +2205,7 @@ pytrafikverket==0.3.6 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.6 +pyunifiprotect==4.20.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 054a38314a4..658fefa8144 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1628,7 +1628,7 @@ pytrafikverket==0.3.6 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.6 +pyunifiprotect==4.20.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From f4a7bb47fe4e4e2c883a1d9d5759dd4dcb39f7ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 08:09:29 -0500 Subject: [PATCH 382/984] Bump zeroconf to 0.105.0 (#100084) --- 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 7d6cc32c8f1..0457f7fd1c3 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.104.0"] + "requirements": ["zeroconf==0.105.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 74aca53df9c..4d2d45de477 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.104.0 +zeroconf==0.105.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 25f365c7825..c588d7b9523 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2766,7 +2766,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.104.0 +zeroconf==0.105.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 658fefa8144..341901b845a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2042,7 +2042,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.104.0 +zeroconf==0.105.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 791482406c701e7cdfbcdd64bf1a7cac1b2e145b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 08:13:25 -0500 Subject: [PATCH 383/984] Cleanup isinstance checks in zeroconf (#100090) --- homeassistant/components/zeroconf/__init__.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b85f9f0fd83..085e720e3df 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -11,7 +11,7 @@ from ipaddress import IPv4Address, IPv6Address import logging import re import sys -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol from zeroconf import ( @@ -303,7 +303,8 @@ def _match_against_data( if key not in match_data: return False match_val = matcher[key] - assert isinstance(match_val, str) + if TYPE_CHECKING: + assert isinstance(match_val, str) if not _memorized_fnmatch(match_data[key], match_val): return False @@ -485,12 +486,14 @@ class ZeroconfDiscovery: continue if ATTR_PROPERTIES in matcher: matcher_props = matcher[ATTR_PROPERTIES] - assert isinstance(matcher_props, dict) + if TYPE_CHECKING: + assert isinstance(matcher_props, dict) if not _match_against_props(matcher_props, props): continue matcher_domain = matcher["domain"] - assert isinstance(matcher_domain, str) + if TYPE_CHECKING: + assert isinstance(matcher_domain, str) context = { "source": config_entries.SOURCE_ZEROCONF, } @@ -516,11 +519,11 @@ def async_get_homekit_discovery( Return the domain to forward the discovery data to """ - if not (model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER)): + if not ( + model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) + ) or not isinstance(model, str): return None - assert isinstance(model, str) - for split_str in _HOMEKIT_MODEL_SPLITS: key = (model.split(split_str))[0] if split_str else model if discovery := homekit_model_lookups.get(key): From d8445a79fc2d0231571b85419f7d26f23eb41b37 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 11 Sep 2023 15:55:27 +0200 Subject: [PATCH 384/984] UniFi streamline loading platforms (#100071) * Streamline loading platforms * Move platform registration logic to UnifiController class --- homeassistant/components/unifi/button.py | 15 +++--- homeassistant/components/unifi/controller.py | 54 ++++++++----------- .../components/unifi/device_tracker.py | 6 +-- homeassistant/components/unifi/image.py | 15 +++--- homeassistant/components/unifi/sensor.py | 6 +-- homeassistant/components/unifi/switch.py | 22 +++----- homeassistant/components/unifi/update.py | 16 +++--- tests/components/unifi/test_device_tracker.py | 2 +- 8 files changed, 57 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 0235f6156cc..7471675123a 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -24,7 +24,6 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -87,13 +86,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up button platform for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - controller.register_platform_add_entities( - UnifiButtonEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiButtonEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index ba188f80135..9f965b424ff 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -21,14 +21,9 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL, - Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import ( - aiohttp_client, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.device_registry import ( DeviceEntry, DeviceEntryType, @@ -39,13 +34,11 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import async_entries_for_config_entry from homeassistant.helpers.event import async_call_later, async_track_time_interval import homeassistant.util.dt as dt_util from .const import ( ATTR_MANUFACTURER, - BLOCK_SWITCH, CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, @@ -162,6 +155,24 @@ class UniFiController: host: str = self.config_entry.data[CONF_HOST] return host + @callback + @staticmethod + def register_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + entity_class: type[UnifiEntity], + descriptions: tuple[UnifiEntityDescription, ...], + requires_admin: bool = False, + ) -> None: + """Register platform for UniFi entity management.""" + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + if requires_admin and not controller.is_admin: + return + controller.register_platform_add_entities( + entity_class, descriptions, async_add_entities + ) + @callback def register_platform_add_entities( self, @@ -251,30 +262,9 @@ class UniFiController: assert self.config_entry.unique_id is not None self.is_admin = self.api.sites[self.config_entry.unique_id].role == "admin" - # Restore clients that are not a part of active clients list. - entity_registry = er.async_get(self.hass) - for entry in async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ): - if entry.domain == Platform.DEVICE_TRACKER: - mac = entry.unique_id.split("-", 1)[0] - elif entry.domain == Platform.SWITCH and entry.unique_id.startswith( - BLOCK_SWITCH - ): - mac = entry.unique_id.split("-", 1)[1] - else: - continue - - if mac in self.api.clients or mac not in self.api.clients_all: - continue - - client = self.api.clients_all[mac] - self.api.clients.process_raw([dict(client.raw)]) - LOGGER.debug( - "Restore disconnected client %s (%s)", - entry.entity_id, - client.mac, - ) + for mac in self.option_block_clients: + if mac not in self.api.clients and mac in self.api.clients_all: + self.api.clients.process_raw([dict(self.api.clients_all[mac].raw)]) self.wireless_clients.update_clients(set(self.api.clients.values())) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index fcfe71a2858..2b7ac04cc0d 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -24,7 +24,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -206,9 +205,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiScannerEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, config_entry, async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 8231b87ee85..2318702f0d1 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -20,7 +20,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -83,13 +82,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up image platform for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - controller.register_platform_add_entities( - UnifiImageEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiImageEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 7cb0b2bbfe3..86c6b0d6352 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -35,7 +35,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -329,9 +328,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiSensorEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, config_entry, async_add_entities, UnifiSensorEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 560e150e63c..0aa39914686 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -43,7 +43,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN +from .const import ATTR_MANUFACTURER from .controller import UniFiController from .entity import ( HandlerT, @@ -320,19 +320,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - for mac in controller.option_block_clients: - if mac not in controller.api.clients and mac in controller.api.clients_all: - controller.api.clients.process_raw( - [dict(controller.api.clients_all[mac].raw)] - ) - - controller.register_platform_add_entities( - UnifiSwitchEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiSwitchEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 6526a02da83..65b26736cf1 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import Any, Generic, TypeVar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as UNIFI_DOMAIN +from .controller import UniFiController from .entity import ( UnifiEntity, UnifiEntityDescription, @@ -29,9 +29,6 @@ from .entity import ( async_device_device_info_fn, ) -if TYPE_CHECKING: - from .controller import UniFiController - LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT", bound=Device) @@ -88,9 +85,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiDeviceUpdateEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiDeviceUpdateEntity, + ENTITY_DESCRIPTIONS, ) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 7b939077e48..99874b3a949 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -946,7 +946,7 @@ async def test_restoring_client( await setup_unifi_integration( hass, aioclient_mock, - options={CONF_BLOCK_CLIENT: True}, + options={CONF_BLOCK_CLIENT: [restored["mac"]]}, clients_response=[client], clients_all_response=[restored, not_restored], ) From 9c65e59cc89101aea8c788b34230d1a62455b91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 11 Sep 2023 23:46:59 +0900 Subject: [PATCH 385/984] Remove AEMET daily precipitation sensor test (#100118) --- tests/components/aemet/test_sensor.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 4d61dde34fc..8237987bf44 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -6,7 +6,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_SNOWY, ) -from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -26,9 +25,6 @@ async def test_aemet_forecast_create_sensors( state = hass.states.get("sensor.aemet_daily_forecast_condition") assert state.state == ATTR_CONDITION_PARTLYCLOUDY - state = hass.states.get("sensor.aemet_daily_forecast_precipitation") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.aemet_daily_forecast_precipitation_probability") assert state.state == "30" From 6ccb74997c8080cf691873d85a05bc17356633ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Sep 2023 16:58:56 +0200 Subject: [PATCH 386/984] Fix ScrapeSensor.async_added_to_hass (#100125) --- homeassistant/components/scrape/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 77131ccb225..bb8c233983d 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -192,9 +192,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti async def async_added_to_hass(self) -> None: """Ensure the data from the initial update is reflected in the state.""" - await ManualTriggerEntity.async_added_to_hass(self) - # https://github.com/python/mypy/issues/15097 - await CoordinatorEntity.async_added_to_hass(self) # type: ignore[arg-type] + await super().async_added_to_hass() self._async_update_from_rest_data() def _async_update_from_rest_data(self) -> None: From 56678851af9802d8dab431e9aeea25f71ed70eb0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 11 Sep 2023 18:03:22 +0200 Subject: [PATCH 387/984] Fix inverse naming of function in Reolink (#100113) --- homeassistant/components/reolink/config_flow.py | 4 ++-- homeassistant/components/reolink/util.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index d924f395c50..e86da1f23a7 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.helpers.device_registry import format_mac from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost -from .util import has_connection_problem +from .util import is_connected _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,7 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): and CONF_PASSWORD in existing_entry.data and existing_entry.data[CONF_HOST] != discovery_info.ip ): - if has_connection_problem(self.hass, existing_entry): + if is_connected(self.hass, existing_entry): _LOGGER.debug( "Reolink DHCP reported new IP '%s', " "but connection to camera seems to be okay, so sticking to IP '%s'", diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index 2ab625647a7..cc9ad192bc3 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -8,16 +8,13 @@ from . import ReolinkData from .const import DOMAIN -def has_connection_problem( - hass: HomeAssistant, config_entry: config_entries.ConfigEntry -) -> bool: - """Check if a existing entry has a connection problem.""" +def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) -> bool: + """Check if an existing entry has a proper connection.""" reolink_data: ReolinkData | None = hass.data.get(DOMAIN, {}).get( config_entry.entry_id ) - connection_problem = ( + return ( reolink_data is not None and config_entry.state == config_entries.ConfigEntryState.LOADED and reolink_data.device_coordinator.last_update_success ) - return connection_problem From 18e08bc79f6970d0fb8645384970861f10c7dfbe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Sep 2023 18:35:48 +0200 Subject: [PATCH 388/984] Bump hatasmota to 0.7.2 (#100129) --- .../components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_light.py | 32 +++++++++++++++++++ tests/components/tasmota/test_switch.py | 32 +++++++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 9843f64fc25..fa34665cd73 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.1"] + "requirements": ["HATasmota==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c588d7b9523..a31e45c47cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.1 +HATasmota==0.7.2 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 341901b845a..7a0eb6c1e25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.1 +HATasmota==0.7.2 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 5c8339a6f89..82fa89c5280 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -1835,3 +1835,35 @@ async def test_entity_id_update_discovery_update( await help_test_entity_id_update_discovery_update( hass, mqtt_mock, Platform.LIGHT, config ) + + +async def test_no_device_name( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test name of lights when no device name is set. + + When the device name is not set, Tasmota uses friendly name 1 as device naem. + This test ensures that case is handled correctly. + """ + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Light 1" + config["fn"][0] = "Light 1" + config["fn"][1] = "Light 2" + config["rl"][0] = 2 + config["rl"][1] = 2 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_1") + assert state is not None + assert state.attributes["friendly_name"] == "Light 1" + + state = hass.states.get("light.light_1_light_2") + assert state is not None + assert state.attributes["friendly_name"] == "Light 1 Light 2" diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index b8d0ed2d060..54d94b46fe8 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -283,3 +283,35 @@ async def test_entity_id_update_discovery_update( await help_test_entity_id_update_discovery_update( hass, mqtt_mock, Platform.SWITCH, config ) + + +async def test_no_device_name( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test name of switches when no device name is set. + + When the device name is not set, Tasmota uses friendly name 1 as device naem. + This test ensures that case is handled correctly. + """ + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Relay 1" + config["fn"][0] = "Relay 1" + config["fn"][1] = "Relay 2" + config["rl"][0] = 1 + config["rl"][1] = 1 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.relay_1") + assert state is not None + assert state.attributes["friendly_name"] == "Relay 1" + + state = hass.states.get("switch.relay_1_relay_2") + assert state is not None + assert state.attributes["friendly_name"] == "Relay 1 Relay 2" From 0fe88d60acd64956c896aa72555c3b91faeddc70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 11:39:10 -0500 Subject: [PATCH 389/984] Guard expensive debug logging with isEnabledFor in alexa (#100137) --- homeassistant/components/alexa/state_report.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 786b2ee5227..f1cf13a0a7e 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -378,8 +378,9 @@ async def async_send_changereport_message( response_text = await response.text() - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) - _LOGGER.debug("Received (%s): %s", response.status, response_text) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: return @@ -531,8 +532,9 @@ async def async_send_doorbell_event_message( response_text = await response.text() - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) - _LOGGER.debug("Received (%s): %s", response.status, response_text) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: return From 17db20fdd7f6eb5235e3d29cf8211f6d2a2d9560 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Mon, 11 Sep 2023 10:06:55 -0700 Subject: [PATCH 390/984] Add Apple WeatherKit integration (#99895) --- CODEOWNERS | 2 + homeassistant/brands/apple.json | 3 +- .../components/weatherkit/__init__.py | 62 + .../components/weatherkit/config_flow.py | 126 + homeassistant/components/weatherkit/const.py | 13 + .../components/weatherkit/coordinator.py | 70 + .../components/weatherkit/manifest.json | 9 + .../components/weatherkit/strings.json | 25 + .../components/weatherkit/weather.py | 249 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/weatherkit/__init__.py | 71 + tests/components/weatherkit/conftest.py | 14 + .../weatherkit/fixtures/weather_response.json | 6344 +++++++++++++++++ .../weatherkit/snapshots/test_weather.ambr | 4087 +++++++++++ .../components/weatherkit/test_config_flow.py | 134 + .../components/weatherkit/test_coordinator.py | 32 + tests/components/weatherkit/test_setup.py | 63 + tests/components/weatherkit/test_weather.py | 115 + 21 files changed, 11431 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/weatherkit/__init__.py create mode 100644 homeassistant/components/weatherkit/config_flow.py create mode 100644 homeassistant/components/weatherkit/const.py create mode 100644 homeassistant/components/weatherkit/coordinator.py create mode 100644 homeassistant/components/weatherkit/manifest.json create mode 100644 homeassistant/components/weatherkit/strings.json create mode 100644 homeassistant/components/weatherkit/weather.py create mode 100644 tests/components/weatherkit/__init__.py create mode 100644 tests/components/weatherkit/conftest.py create mode 100644 tests/components/weatherkit/fixtures/weather_response.json create mode 100644 tests/components/weatherkit/snapshots/test_weather.ambr create mode 100644 tests/components/weatherkit/test_config_flow.py create mode 100644 tests/components/weatherkit/test_coordinator.py create mode 100644 tests/components/weatherkit/test_setup.py create mode 100644 tests/components/weatherkit/test_weather.py diff --git a/CODEOWNERS b/CODEOWNERS index 8a454cf775a..29c744ce42e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1404,6 +1404,8 @@ build.json @home-assistant/supervisor /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core /tests/components/weather/ @home-assistant/core +/homeassistant/components/weatherkit/ @tjhorner +/tests/components/weatherkit/ @tjhorner /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webostv/ @thecode diff --git a/homeassistant/brands/apple.json b/homeassistant/brands/apple.json index 00f646e435e..b0b66de0bcc 100644 --- a/homeassistant/brands/apple.json +++ b/homeassistant/brands/apple.json @@ -7,6 +7,7 @@ "homekit", "ibeacon", "icloud", - "itunes" + "itunes", + "weatherkit" ] } diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py new file mode 100644 index 00000000000..fb41ffc1084 --- /dev/null +++ b/homeassistant/components/weatherkit/__init__.py @@ -0,0 +1,62 @@ +"""Integration for Apple's WeatherKit API.""" +from __future__ import annotations + +from apple_weatherkit.client import ( + WeatherKitApiClient, + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, + LOGGER, +) +from .coordinator import WeatherKitDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + hass.data.setdefault(DOMAIN, {}) + coordinator = WeatherKitDataUpdateCoordinator( + hass=hass, + client=WeatherKitApiClient( + key_id=entry.data[CONF_KEY_ID], + service_id=entry.data[CONF_SERVICE_ID], + team_id=entry.data[CONF_TEAM_ID], + key_pem=entry.data[CONF_KEY_PEM], + session=async_get_clientsession(hass), + ), + ) + + try: + await coordinator.update_supported_data_sets() + except WeatherKitApiClientAuthenticationError as ex: + LOGGER.error("Authentication error initializing integration: %s", ex) + return False + except WeatherKitApiClientError as ex: + raise ConfigEntryNotReady from ex + + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unloaded diff --git a/homeassistant/components/weatherkit/config_flow.py b/homeassistant/components/weatherkit/config_flow.py new file mode 100644 index 00000000000..d9db70dde11 --- /dev/null +++ b/homeassistant/components/weatherkit/config_flow.py @@ -0,0 +1,126 @@ +"""Adds config flow for WeatherKit.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from apple_weatherkit.client import ( + WeatherKitApiClient, + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientCommunicationError, + WeatherKitApiClientError, +) +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + LocationSelector, + LocationSelectorConfig, + TextSelector, + TextSelectorConfig, +) + +from .const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, + LOGGER, +) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCATION): LocationSelector( + LocationSelectorConfig(radius=False, icon="") + ), + # Auth + vol.Required(CONF_KEY_ID): str, + vol.Required(CONF_SERVICE_ID): str, + vol.Required(CONF_TEAM_ID): str, + vol.Required(CONF_KEY_PEM): TextSelector( + TextSelectorConfig( + multiline=True, + ) + ), + } +) + + +class WeatherKitUnsupportedLocationError(Exception): + """Error to indicate a location is unsupported.""" + + +class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for WeatherKit.""" + + VERSION = 1 + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> data_entry_flow.FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + try: + await self._test_config(user_input) + except WeatherKitUnsupportedLocationError as exception: + LOGGER.error(exception) + errors["base"] = "unsupported_location" + except WeatherKitApiClientAuthenticationError as exception: + LOGGER.warning(exception) + errors["base"] = "invalid_auth" + except WeatherKitApiClientCommunicationError as exception: + LOGGER.error(exception) + errors["base"] = "cannot_connect" + except WeatherKitApiClientError as exception: + LOGGER.exception(exception) + errors["base"] = "unknown" + else: + # Flatten location + location = user_input.pop(CONF_LOCATION) + user_input[CONF_LATITUDE] = location[CONF_LATITUDE] + user_input[CONF_LONGITUDE] = location[CONF_LONGITUDE] + + return self.async_create_entry( + title=f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}", + data=user_input, + ) + + suggested_values: Mapping[str, Any] = { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + } + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, suggested_values) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + async def _test_config(self, user_input: dict[str, Any]) -> None: + """Validate credentials.""" + client = WeatherKitApiClient( + key_id=user_input[CONF_KEY_ID], + service_id=user_input[CONF_SERVICE_ID], + team_id=user_input[CONF_TEAM_ID], + key_pem=user_input[CONF_KEY_PEM], + session=async_get_clientsession(self.hass), + ) + + location = user_input[CONF_LOCATION] + availability = await client.get_availability( + location[CONF_LATITUDE], + location[CONF_LONGITUDE], + ) + + if len(availability) == 0: + raise WeatherKitUnsupportedLocationError( + "API does not support this location" + ) diff --git a/homeassistant/components/weatherkit/const.py b/homeassistant/components/weatherkit/const.py new file mode 100644 index 00000000000..f2ef7e4c720 --- /dev/null +++ b/homeassistant/components/weatherkit/const.py @@ -0,0 +1,13 @@ +"""Constants for WeatherKit.""" +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) + +NAME = "Apple WeatherKit" +DOMAIN = "weatherkit" +ATTRIBUTION = "Data provided by Apple Weather. https://developer.apple.com/weatherkit/data-source-attribution/" + +CONF_KEY_ID = "key_id" +CONF_SERVICE_ID = "service_id" +CONF_TEAM_ID = "team_id" +CONF_KEY_PEM = "key_pem" diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py new file mode 100644 index 00000000000..a918ce0f850 --- /dev/null +++ b/homeassistant/components/weatherkit/coordinator.py @@ -0,0 +1,70 @@ +"""DataUpdateCoordinator for WeatherKit integration.""" +from __future__ import annotations + +from datetime import timedelta + +from apple_weatherkit import DataSetType +from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +REQUESTED_DATA_SETS = [ + DataSetType.CURRENT_WEATHER, + DataSetType.DAILY_FORECAST, + DataSetType.HOURLY_FORECAST, +] + + +class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + config_entry: ConfigEntry + supported_data_sets: list[DataSetType] | None = None + + def __init__( + self, + hass: HomeAssistant, + client: WeatherKitApiClient, + ) -> None: + """Initialize.""" + self.client = client + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def update_supported_data_sets(self): + """Obtain the supported data sets for this location and store them.""" + supported_data_sets = await self.client.get_availability( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + ) + + self.supported_data_sets = [ + data_set + for data_set in REQUESTED_DATA_SETS + if data_set in supported_data_sets + ] + + LOGGER.debug("Supported data sets: %s", self.supported_data_sets) + + async def _async_update_data(self): + """Update the current weather and forecasts.""" + try: + if not self.supported_data_sets: + await self.update_supported_data_sets() + + return await self.client.get_weather_data( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + self.supported_data_sets, + ) + except WeatherKitApiClientError as exception: + raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json new file mode 100644 index 00000000000..984e36483c7 --- /dev/null +++ b/homeassistant/components/weatherkit/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "weatherkit", + "name": "Apple WeatherKit", + "codeowners": ["@tjhorner"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/weatherkit", + "iot_class": "cloud_polling", + "requirements": ["apple_weatherkit==1.0.1"] +} diff --git a/homeassistant/components/weatherkit/strings.json b/homeassistant/components/weatherkit/strings.json new file mode 100644 index 00000000000..4581028f209 --- /dev/null +++ b/homeassistant/components/weatherkit/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "WeatherKit setup", + "description": "Enter your location details and WeatherKit authentication credentials below.", + "data": { + "name": "Name", + "location": "[%key:common::config_flow::data::location%]", + "key_id": "Key ID", + "team_id": "Apple team ID", + "service_id": "Service ID", + "key_pem": "Private key (.p8)" + } + } + }, + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "unsupported_location": "Apple WeatherKit does not provide data for this location.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py new file mode 100644 index 00000000000..fc6b0dac1cb --- /dev/null +++ b/homeassistant/components/weatherkit/weather.py @@ -0,0 +1,249 @@ +"""Weather entity for Apple WeatherKit integration.""" + +from typing import Any, cast + +from apple_weatherkit import DataSetType + +from homeassistant.components.weather import ( + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + UnitOfLength, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import WeatherKitDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a weather entity from a config_entry.""" + coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities([WeatherKitWeather(coordinator)]) + + +condition_code_to_hass = { + "BlowingDust": "windy", + "Clear": "sunny", + "Cloudy": "cloudy", + "Foggy": "fog", + "Haze": "fog", + "MostlyClear": "sunny", + "MostlyCloudy": "cloudy", + "PartlyCloudy": "partlycloudy", + "Smoky": "fog", + "Breezy": "windy", + "Windy": "windy", + "Drizzle": "rainy", + "HeavyRain": "pouring", + "IsolatedThunderstorms": "lightning", + "Rain": "rainy", + "SunShowers": "rainy", + "ScatteredThunderstorms": "lightning", + "StrongStorms": "lightning", + "Thunderstorms": "lightning", + "Frigid": "snowy", + "Hail": "hail", + "Hot": "sunny", + "Flurries": "snowy", + "Sleet": "snowy", + "Snow": "snowy", + "SunFlurries": "snowy", + "WintryMix": "snowy", + "Blizzard": "snowy", + "BlowingSnow": "snowy", + "FreezingDrizzle": "snowy-rainy", + "FreezingRain": "snowy-rainy", + "HeavySnow": "snowy", + "Hurricane": "exceptional", + "TropicalStorm": "exceptional", +} + + +def _map_daily_forecast(forecast) -> Forecast: + return { + "datetime": forecast.get("forecastStart"), + "condition": condition_code_to_hass[forecast.get("conditionCode")], + "native_temperature": forecast.get("temperatureMax"), + "native_templow": forecast.get("temperatureMin"), + "native_precipitation": forecast.get("precipitationAmount"), + "precipitation_probability": forecast.get("precipitationChance") * 100, + "uv_index": forecast.get("maxUvIndex"), + } + + +def _map_hourly_forecast(forecast) -> Forecast: + return { + "datetime": forecast.get("forecastStart"), + "condition": condition_code_to_hass[forecast.get("conditionCode")], + "native_temperature": forecast.get("temperature"), + "native_apparent_temperature": forecast.get("temperatureApparent"), + "native_dew_point": forecast.get("temperatureDewPoint"), + "native_pressure": forecast.get("pressure"), + "native_wind_gust_speed": forecast.get("windGust"), + "native_wind_speed": forecast.get("windSpeed"), + "wind_bearing": forecast.get("windDirection"), + "humidity": forecast.get("humidity") * 100, + "native_precipitation": forecast.get("precipitationAmount"), + "precipitation_probability": forecast.get("precipitationChance") * 100, + "cloud_coverage": forecast.get("cloudCover") * 100, + "uv_index": forecast.get("uvIndex"), + } + + +class WeatherKitWeather( + SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator] +): + """Weather entity for Apple WeatherKit integration.""" + + _attr_attribution = ATTRIBUTION + + _attr_has_entity_name = True + _attr_name = None + + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.MBAR + _attr_native_visibility_unit = UnitOfLength.KILOMETERS + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + + def __init__( + self, + coordinator: WeatherKitDataUpdateCoordinator, + ) -> None: + """Initialise the platform with a data instance and site.""" + super().__init__(coordinator) + config_data = coordinator.config_entry.data + self._attr_unique_id = ( + f"{config_data[CONF_LATITUDE]}-{config_data[CONF_LONGITUDE]}" + ) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="Apple Weather", + ) + + @property + def supported_features(self) -> WeatherEntityFeature: + """Determine supported features based on available data sets reported by WeatherKit.""" + if not self.coordinator.supported_data_sets: + return WeatherEntityFeature(0) + + features = WeatherEntityFeature(0) + if DataSetType.DAILY_FORECAST in self.coordinator.supported_data_sets: + features |= WeatherEntityFeature.FORECAST_DAILY + if DataSetType.HOURLY_FORECAST in self.coordinator.supported_data_sets: + features |= WeatherEntityFeature.FORECAST_HOURLY + return features + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data.""" + return self.coordinator.data + + @property + def current_weather(self) -> dict[str, Any]: + """Return current weather data.""" + return self.data["currentWeather"] + + @property + def condition(self) -> str | None: + """Return the current condition.""" + condition_code = cast(str, self.current_weather.get("conditionCode")) + condition = condition_code_to_hass[condition_code] + + if condition == "sunny" and self.current_weather.get("daylight") is False: + condition = "clear-night" + + return condition + + @property + def native_temperature(self) -> float | None: + """Return the current temperature.""" + return self.current_weather.get("temperature") + + @property + def native_apparent_temperature(self) -> float | None: + """Return the current apparent_temperature.""" + return self.current_weather.get("temperatureApparent") + + @property + def native_dew_point(self) -> float | None: + """Return the current dew_point.""" + return self.current_weather.get("temperatureDewPoint") + + @property + def native_pressure(self) -> float | None: + """Return the current pressure.""" + return self.current_weather.get("pressure") + + @property + def humidity(self) -> float | None: + """Return the current humidity.""" + return cast(float, self.current_weather.get("humidity")) * 100 + + @property + def cloud_coverage(self) -> float | None: + """Return the current cloud_coverage.""" + return cast(float, self.current_weather.get("cloudCover")) * 100 + + @property + def uv_index(self) -> float | None: + """Return the current uv_index.""" + return self.current_weather.get("uvIndex") + + @property + def native_visibility(self) -> float | None: + """Return the current visibility.""" + return cast(float, self.current_weather.get("visibility")) / 1000 + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the current wind_gust_speed.""" + return self.current_weather.get("windGust") + + @property + def native_wind_speed(self) -> float | None: + """Return the current wind_speed.""" + return self.current_weather.get("windSpeed") + + @property + def wind_bearing(self) -> float | None: + """Return the current wind_bearing.""" + return self.current_weather.get("windDirection") + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast.""" + daily_forecast = self.data.get("forecastDaily") + if not daily_forecast: + return None + + forecast = daily_forecast.get("days") + return [_map_daily_forecast(f) for f in forecast] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast.""" + hourly_forecast = self.data.get("forecastHourly") + if not hourly_forecast: + return None + + forecast = hourly_forecast.get("hours") + return [_map_hourly_forecast(f) for f in forecast] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0f55df7cc99..1557df8f33b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -519,6 +519,7 @@ FLOWS = { "waqi", "watttime", "waze_travel_time", + "weatherkit", "webostv", "wemo", "whirlpool", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5eaf1b8d0a4..7cad78a49fc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -335,6 +335,12 @@ "config_flow": false, "iot_class": "local_polling", "name": "Apple iTunes" + }, + "weatherkit": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Apple WeatherKit" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index a31e45c47cc..4f22107c76f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -423,6 +423,9 @@ anthemav==1.4.1 # homeassistant.components.apcupsd apcaccess==0.0.13 +# homeassistant.components.weatherkit +apple_weatherkit==1.0.1 + # homeassistant.components.apprise apprise==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a0eb6c1e25..9df6f6b1a11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -389,6 +389,9 @@ anthemav==1.4.1 # homeassistant.components.apcupsd apcaccess==0.0.13 +# homeassistant.components.weatherkit +apple_weatherkit==1.0.1 + # homeassistant.components.apprise apprise==1.4.5 diff --git a/tests/components/weatherkit/__init__.py b/tests/components/weatherkit/__init__.py new file mode 100644 index 00000000000..5118c44c45b --- /dev/null +++ b/tests/components/weatherkit/__init__.py @@ -0,0 +1,71 @@ +"""Tests for the Apple WeatherKit integration.""" +from unittest.mock import patch + +from apple_weatherkit import DataSetType + +from homeassistant.components.weatherkit.const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, +) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + +EXAMPLE_CONFIG_DATA = { + CONF_LATITUDE: 35.4690101707532, + CONF_LONGITUDE: 135.74817234593166, + CONF_KEY_ID: "QABCDEFG123", + CONF_SERVICE_ID: "io.home-assistant.testing", + CONF_TEAM_ID: "ABCD123456", + CONF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nwhateverkey\n-----END PRIVATE KEY-----", +} + + +async def init_integration( + hass: HomeAssistant, + is_night_time: bool = False, + has_hourly_forecast: bool = True, + has_daily_forecast: bool = True, +) -> MockConfigEntry: + """Set up the WeatherKit integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + weather_response = load_json_object_fixture("weatherkit/weather_response.json") + + available_data_sets = [DataSetType.CURRENT_WEATHER] + + if is_night_time: + weather_response["currentWeather"]["daylight"] = False + weather_response["currentWeather"]["conditionCode"] = "Clear" + + if not has_daily_forecast: + del weather_response["forecastDaily"] + else: + available_data_sets.append(DataSetType.DAILY_FORECAST) + + if not has_hourly_forecast: + del weather_response["forecastHourly"] + else: + available_data_sets.append(DataSetType.HOURLY_FORECAST) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + return_value=weather_response, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=available_data_sets, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/weatherkit/conftest.py b/tests/components/weatherkit/conftest.py new file mode 100644 index 00000000000..7cfa2f7eef5 --- /dev/null +++ b/tests/components/weatherkit/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Apple WeatherKit 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.weatherkit.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/weatherkit/fixtures/weather_response.json b/tests/components/weatherkit/fixtures/weather_response.json new file mode 100644 index 00000000000..c2d619d85d8 --- /dev/null +++ b/tests/components/weatherkit/fixtures/weather_response.json @@ -0,0 +1,6344 @@ +{ + "currentWeather": { + "name": "CurrentWeather", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T22:08:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "asOf": "2023-09-08T22:03:04Z", + "cloudCover": 0.62, + "cloudCoverLowAltPct": 0.35, + "cloudCoverMidAltPct": 0.22, + "cloudCoverHighAltPct": 0.32, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.91, + "precipitationIntensity": 0.0, + "pressure": 1009.8, + "pressureTrend": "rising", + "temperature": 22.9, + "temperatureApparent": 24.92, + "temperatureDewPoint": 21.28, + "uvIndex": 1, + "visibility": 20965.22, + "windDirection": 259, + "windGust": 10.53, + "windSpeed": 5.23 + }, + "forecastDaily": { + "name": "DailyForecast", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T23:03:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "days": [ + { + "forecastStart": "2023-09-08T15:00:00Z", + "forecastEnd": "2023-09-09T15:00:00Z", + "conditionCode": "MostlyCloudy", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonset": "2023-09-09T06:10:26Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-09T14:54:36Z", + "solarNoon": "2023-09-09T02:54:26Z", + "sunrise": "2023-09-08T20:34:47Z", + "sunriseCivil": "2023-09-08T20:09:00Z", + "sunriseNautical": "2023-09-08T19:38:47Z", + "sunriseAstronomical": "2023-09-08T19:07:36Z", + "sunset": "2023-09-09T09:13:58Z", + "sunsetCivil": "2023-09-09T09:39:40Z", + "sunsetNautical": "2023-09-09T10:09:52Z", + "sunsetAstronomical": "2023-09-09T10:40:54Z", + "temperatureMax": 28.62, + "temperatureMin": 21.18, + "daytimeForecast": { + "forecastStart": "2023-09-08T22:00:00Z", + "forecastEnd": "2023-09-09T10:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 318, + "windSpeed": 7.36 + }, + "overnightForecast": { + "forecastStart": "2023-09-09T10:00:00Z", + "forecastEnd": "2023-09-09T22:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 166, + "windSpeed": 2.99 + }, + "restOfDayForecast": { + "forecastStart": "2023-09-08T22:03:04Z", + "forecastEnd": "2023-09-09T15:00:00Z", + "cloudCover": 0.69, + "conditionCode": "MostlyCloudy", + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 315, + "windSpeed": 5.78 + } + }, + { + "forecastStart": "2023-09-09T15:00:00Z", + "forecastEnd": "2023-09-10T15:00:00Z", + "conditionCode": "Rain", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-09T15:36:16Z", + "moonset": "2023-09-10T06:54:57Z", + "precipitationAmount": 3.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-10T14:54:15Z", + "solarNoon": "2023-09-10T02:54:05Z", + "sunrise": "2023-09-09T20:35:31Z", + "sunriseCivil": "2023-09-09T20:09:47Z", + "sunriseNautical": "2023-09-09T19:39:37Z", + "sunriseAstronomical": "2023-09-09T19:08:32Z", + "sunset": "2023-09-10T09:12:31Z", + "sunsetCivil": "2023-09-10T09:38:11Z", + "sunsetNautical": "2023-09-10T10:08:20Z", + "sunsetAstronomical": "2023-09-10T10:39:18Z", + "temperatureMax": 30.64, + "temperatureMin": 21.0, + "daytimeForecast": { + "forecastStart": "2023-09-09T22:00:00Z", + "forecastEnd": "2023-09-10T10:00:00Z", + "cloudCover": 0.76, + "conditionCode": "Rain", + "humidity": 0.73, + "precipitationAmount": 3.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.35, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 96, + "windSpeed": 4.94 + }, + "overnightForecast": { + "forecastStart": "2023-09-10T10:00:00Z", + "forecastEnd": "2023-09-10T22:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 141, + "windSpeed": 7.84 + } + }, + { + "forecastStart": "2023-09-10T15:00:00Z", + "forecastEnd": "2023-09-11T15:00:00Z", + "conditionCode": "MostlyCloudy", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-10T16:34:55Z", + "moonset": "2023-09-11T07:32:40Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-11T14:53:54Z", + "solarNoon": "2023-09-11T02:53:44Z", + "sunrise": "2023-09-10T20:36:16Z", + "sunriseCivil": "2023-09-10T20:10:33Z", + "sunriseNautical": "2023-09-10T19:40:27Z", + "sunriseAstronomical": "2023-09-10T19:09:28Z", + "sunset": "2023-09-11T09:11:04Z", + "sunsetCivil": "2023-09-11T09:36:43Z", + "sunsetNautical": "2023-09-11T10:06:47Z", + "sunsetAstronomical": "2023-09-11T10:37:41Z", + "temperatureMax": 30.44, + "temperatureMin": 23.14, + "daytimeForecast": { + "forecastStart": "2023-09-10T22:00:00Z", + "forecastEnd": "2023-09-11T10:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 139, + "windSpeed": 14.23 + }, + "overnightForecast": { + "forecastStart": "2023-09-11T10:00:00Z", + "forecastEnd": "2023-09-11T22:00:00Z", + "cloudCover": 0.83, + "conditionCode": "MostlyCloudy", + "humidity": 0.85, + "precipitationAmount": 0.5, + "precipitationAmountByType": {}, + "precipitationChance": 0.22, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 144, + "windSpeed": 11.26 + } + }, + { + "forecastStart": "2023-09-11T15:00:00Z", + "forecastEnd": "2023-09-12T15:00:00Z", + "conditionCode": "Drizzle", + "maxUvIndex": 5, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-11T17:34:35Z", + "moonset": "2023-09-12T08:04:36Z", + "precipitationAmount": 0.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.47, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-12T14:53:33Z", + "solarNoon": "2023-09-12T02:53:22Z", + "sunrise": "2023-09-11T20:37:00Z", + "sunriseCivil": "2023-09-11T20:11:20Z", + "sunriseNautical": "2023-09-11T19:41:16Z", + "sunriseAstronomical": "2023-09-11T19:10:23Z", + "sunset": "2023-09-12T09:09:37Z", + "sunsetCivil": "2023-09-12T09:35:14Z", + "sunsetNautical": "2023-09-12T10:05:15Z", + "sunsetAstronomical": "2023-09-12T10:36:04Z", + "temperatureMax": 30.42, + "temperatureMin": 23.15, + "daytimeForecast": { + "forecastStart": "2023-09-11T22:00:00Z", + "forecastEnd": "2023-09-12T10:00:00Z", + "cloudCover": 0.68, + "conditionCode": "Drizzle", + "humidity": 0.72, + "precipitationAmount": 0.2, + "precipitationAmountByType": {}, + "precipitationChance": 0.32, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 140, + "windSpeed": 12.44 + }, + "overnightForecast": { + "forecastStart": "2023-09-12T10:00:00Z", + "forecastEnd": "2023-09-12T22:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.47, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 148, + "windSpeed": 8.78 + } + }, + { + "forecastStart": "2023-09-12T15:00:00Z", + "forecastEnd": "2023-09-13T15:00:00Z", + "conditionCode": "Rain", + "maxUvIndex": 6, + "moonPhase": "new", + "moonrise": "2023-09-12T18:33:48Z", + "moonset": "2023-09-13T08:32:25Z", + "precipitationAmount": 7.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.37, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-13T14:53:11Z", + "solarNoon": "2023-09-13T02:53:01Z", + "sunrise": "2023-09-12T20:37:45Z", + "sunriseCivil": "2023-09-12T20:12:07Z", + "sunriseNautical": "2023-09-12T19:42:05Z", + "sunriseAstronomical": "2023-09-12T19:11:18Z", + "sunset": "2023-09-13T09:08:10Z", + "sunsetCivil": "2023-09-13T09:33:46Z", + "sunsetNautical": "2023-09-13T10:03:43Z", + "sunsetAstronomical": "2023-09-13T10:34:27Z", + "temperatureMax": 30.4, + "temperatureMin": 22.15, + "daytimeForecast": { + "forecastStart": "2023-09-12T22:00:00Z", + "forecastEnd": "2023-09-13T10:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "humidity": 0.7, + "precipitationAmount": 7.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.24, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 70, + "windSpeed": 7.79 + }, + "overnightForecast": { + "forecastStart": "2023-09-13T10:00:00Z", + "forecastEnd": "2023-09-13T22:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 151, + "windSpeed": 5.69 + } + }, + { + "forecastStart": "2023-09-13T15:00:00Z", + "forecastEnd": "2023-09-14T15:00:00Z", + "conditionCode": "Drizzle", + "maxUvIndex": 6, + "moonPhase": "new", + "moonrise": "2023-09-13T19:31:58Z", + "moonset": "2023-09-14T08:57:12Z", + "precipitationAmount": 0.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-14T14:52:50Z", + "solarNoon": "2023-09-14T02:52:40Z", + "sunrise": "2023-09-13T20:38:29Z", + "sunriseCivil": "2023-09-13T20:12:53Z", + "sunriseNautical": "2023-09-13T19:42:55Z", + "sunriseAstronomical": "2023-09-13T19:12:12Z", + "sunset": "2023-09-14T09:06:42Z", + "sunsetCivil": "2023-09-14T09:32:17Z", + "sunsetNautical": "2023-09-14T10:02:11Z", + "sunsetAstronomical": "2023-09-14T10:32:51Z", + "temperatureMax": 30.98, + "temperatureMin": 22.62, + "daytimeForecast": { + "forecastStart": "2023-09-13T22:00:00Z", + "forecastEnd": "2023-09-14T10:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "humidity": 0.71, + "precipitationAmount": 0.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 11, + "windSpeed": 5.37 + }, + "overnightForecast": { + "forecastStart": "2023-09-14T10:00:00Z", + "forecastEnd": "2023-09-14T22:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.52, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 166, + "windSpeed": 5.09 + } + }, + { + "forecastStart": "2023-09-14T15:00:00Z", + "forecastEnd": "2023-09-15T15:00:00Z", + "conditionCode": "PartlyCloudy", + "maxUvIndex": 7, + "moonPhase": "new", + "moonrise": "2023-09-14T20:29:10Z", + "moonset": "2023-09-15T09:20:27Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.52, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-15T14:52:28Z", + "solarNoon": "2023-09-15T02:52:18Z", + "sunrise": "2023-09-14T20:39:14Z", + "sunriseCivil": "2023-09-14T20:13:39Z", + "sunriseNautical": "2023-09-14T19:43:43Z", + "sunriseAstronomical": "2023-09-14T19:13:06Z", + "sunset": "2023-09-15T09:05:15Z", + "sunsetCivil": "2023-09-15T09:30:48Z", + "sunsetNautical": "2023-09-15T10:00:39Z", + "sunsetAstronomical": "2023-09-15T10:31:15Z", + "temperatureMax": 31.47, + "temperatureMin": 22.4, + "daytimeForecast": { + "forecastStart": "2023-09-14T22:00:00Z", + "forecastEnd": "2023-09-15T10:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.29, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 356, + "windSpeed": 7.68 + }, + "overnightForecast": { + "forecastStart": "2023-09-15T10:00:00Z", + "forecastEnd": "2023-09-15T22:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 179, + "windSpeed": 5.46 + } + }, + { + "forecastStart": "2023-09-15T15:00:00Z", + "forecastEnd": "2023-09-16T15:00:00Z", + "conditionCode": "MostlyClear", + "maxUvIndex": 8, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-15T21:26:00Z", + "moonset": "2023-09-16T09:43:08Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-16T14:52:07Z", + "solarNoon": "2023-09-16T02:51:57Z", + "sunrise": "2023-09-15T20:39:59Z", + "sunriseCivil": "2023-09-15T20:14:26Z", + "sunriseNautical": "2023-09-15T19:44:32Z", + "sunriseAstronomical": "2023-09-15T19:13:59Z", + "sunset": "2023-09-16T09:03:47Z", + "sunsetCivil": "2023-09-16T09:29:19Z", + "sunsetNautical": "2023-09-16T09:59:07Z", + "sunsetAstronomical": "2023-09-16T10:29:39Z", + "temperatureMax": 31.77, + "temperatureMin": 23.29, + "daytimeForecast": { + "forecastStart": "2023-09-15T22:00:00Z", + "forecastEnd": "2023-09-16T10:00:00Z", + "cloudCover": 0.18, + "conditionCode": "MostlyClear", + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 68, + "windSpeed": 6.49 + }, + "overnightForecast": { + "forecastStart": "2023-09-16T10:00:00Z", + "forecastEnd": "2023-09-16T22:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 158, + "windSpeed": 7.94 + } + }, + { + "forecastStart": "2023-09-16T15:00:00Z", + "forecastEnd": "2023-09-17T15:00:00Z", + "conditionCode": "Thunderstorms", + "maxUvIndex": 8, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-16T22:23:20Z", + "moonset": "2023-09-17T10:06:21Z", + "precipitationAmount": 5.3, + "precipitationAmountByType": {}, + "precipitationChance": 0.35, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-17T14:51:45Z", + "solarNoon": "2023-09-17T02:51:35Z", + "sunrise": "2023-09-16T20:40:43Z", + "sunriseCivil": "2023-09-16T20:15:12Z", + "sunriseNautical": "2023-09-16T19:45:21Z", + "sunriseAstronomical": "2023-09-16T19:14:53Z", + "sunset": "2023-09-17T09:02:19Z", + "sunsetCivil": "2023-09-17T09:27:50Z", + "sunsetNautical": "2023-09-17T09:57:36Z", + "sunsetAstronomical": "2023-09-17T10:28:03Z", + "temperatureMax": 30.68, + "temperatureMin": 23.21, + "daytimeForecast": { + "forecastStart": "2023-09-16T22:00:00Z", + "forecastEnd": "2023-09-17T10:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "humidity": 0.69, + "precipitationAmount": 3.8, + "precipitationAmountByType": {}, + "precipitationChance": 0.22, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 273, + "windSpeed": 8.43 + }, + "overnightForecast": { + "forecastStart": "2023-09-17T10:00:00Z", + "forecastEnd": "2023-09-17T22:00:00Z", + "cloudCover": 0.52, + "conditionCode": "Thunderstorms", + "humidity": 0.9, + "precipitationAmount": 2.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.43, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 228, + "windSpeed": 4.22 + } + }, + { + "forecastStart": "2023-09-17T15:00:00Z", + "forecastEnd": "2023-09-18T15:00:00Z", + "conditionCode": "Thunderstorms", + "maxUvIndex": 6, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-17T23:22:07Z", + "moonset": "2023-09-18T10:31:34Z", + "precipitationAmount": 2.1, + "precipitationAmountByType": {}, + "precipitationChance": 0.49, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-18T14:51:24Z", + "solarNoon": "2023-09-18T02:51:14Z", + "sunrise": "2023-09-17T20:41:28Z", + "sunriseCivil": "2023-09-17T20:15:58Z", + "sunriseNautical": "2023-09-17T19:46:09Z", + "sunriseAstronomical": "2023-09-17T19:15:46Z", + "sunset": "2023-09-18T09:00:51Z", + "sunsetCivil": "2023-09-18T09:26:21Z", + "sunsetNautical": "2023-09-18T09:56:06Z", + "sunsetAstronomical": "2023-09-18T10:26:28Z", + "temperatureMax": 28.15, + "temperatureMin": 22.47, + "daytimeForecast": { + "forecastStart": "2023-09-17T22:00:00Z", + "forecastEnd": "2023-09-18T10:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "humidity": 0.73, + "precipitationAmount": 1.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.3, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 336, + "windSpeed": 12.53 + }, + "overnightForecast": { + "forecastStart": "2023-09-18T10:00:00Z", + "forecastEnd": "2023-09-18T22:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.26, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 162, + "windSpeed": 8.23 + } + } + ] + }, + "forecastHourly": { + "name": "HourlyForecast", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T23:03:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "hours": [ + { + "forecastStart": "2023-09-08T14:00:00Z", + "cloudCover": 0.79, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.24, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.55, + "temperatureApparent": 24.61, + "temperatureDewPoint": 21.47, + "uvIndex": 0, + "visibility": 17056.0, + "windDirection": 264, + "windGust": 13.44, + "windSpeed": 6.62 + }, + { + "forecastStart": "2023-09-08T15:00:00Z", + "cloudCover": 0.8, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.24, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.38, + "temperatureApparent": 24.42, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 19190.0, + "windDirection": 261, + "windGust": 11.91, + "windSpeed": 6.64 + }, + { + "forecastStart": "2023-09-08T16:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.12, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.96, + "temperatureApparent": 23.84, + "temperatureDewPoint": 21.09, + "uvIndex": 0, + "visibility": 17045.0, + "windDirection": 252, + "windGust": 11.15, + "windSpeed": 6.14 + }, + { + "forecastStart": "2023-09-08T17:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.03, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.73, + "temperatureApparent": 23.54, + "temperatureDewPoint": 20.93, + "uvIndex": 0, + "visibility": 16267.0, + "windDirection": 248, + "windGust": 11.57, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-08T18:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.57, + "temperatureApparent": 23.32, + "temperatureDewPoint": 20.77, + "uvIndex": 0, + "visibility": 17319.0, + "windDirection": 237, + "windGust": 12.42, + "windSpeed": 5.86 + }, + { + "forecastStart": "2023-09-08T19:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.96, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.03, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.33, + "temperatureApparent": 23.01, + "temperatureDewPoint": 20.6, + "uvIndex": 0, + "visibility": 16586.0, + "windDirection": 224, + "windGust": 11.3, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-08T20:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.96, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.18, + "temperatureApparent": 22.8, + "temperatureDewPoint": 20.45, + "uvIndex": 0, + "visibility": 15051.0, + "windDirection": 221, + "windGust": 10.57, + "windSpeed": 5.13 + }, + { + "forecastStart": "2023-09-08T21:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.41, + "temperatureApparent": 23.07, + "temperatureDewPoint": 20.54, + "uvIndex": 0, + "visibility": 14835.0, + "windDirection": 237, + "windGust": 10.63, + "windSpeed": 5.7 + }, + { + "forecastStart": "2023-09-08T22:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.79, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.84, + "temperatureApparent": 24.85, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 20790.0, + "windDirection": 258, + "windGust": 10.47, + "windSpeed": 5.22 + }, + { + "forecastStart": "2023-09-08T23:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.98, + "temperatureApparent": 26.11, + "temperatureDewPoint": 21.34, + "uvIndex": 2, + "visibility": 22144.0, + "windDirection": 282, + "windGust": 12.74, + "windSpeed": 5.71 + }, + { + "forecastStart": "2023-09-09T00:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.35, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.13, + "temperatureApparent": 27.42, + "temperatureDewPoint": 21.52, + "uvIndex": 3, + "visibility": 23376.0, + "windDirection": 294, + "windGust": 13.87, + "windSpeed": 6.53 + }, + { + "forecastStart": "2023-09-09T01:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.48, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.52, + "temperatureApparent": 29.04, + "temperatureDewPoint": 21.77, + "uvIndex": 5, + "visibility": 23945.0, + "windDirection": 308, + "windGust": 16.04, + "windSpeed": 6.54 + }, + { + "forecastStart": "2023-09-09T02:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.55, + "temperatureApparent": 30.26, + "temperatureDewPoint": 21.96, + "uvIndex": 6, + "visibility": 19031.0, + "windDirection": 314, + "windGust": 18.1, + "windSpeed": 7.32 + }, + { + "forecastStart": "2023-09-09T03:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.86, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.27, + "temperatureApparent": 31.12, + "temperatureDewPoint": 22.09, + "uvIndex": 6, + "visibility": 20583.0, + "windDirection": 317, + "windGust": 20.77, + "windSpeed": 9.1 + }, + { + "forecastStart": "2023-09-09T04:00:00Z", + "cloudCover": 0.69, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.65, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.62, + "temperatureApparent": 31.53, + "temperatureDewPoint": 22.13, + "uvIndex": 6, + "visibility": 20816.0, + "windDirection": 311, + "windGust": 21.27, + "windSpeed": 10.21 + }, + { + "forecastStart": "2023-09-09T05:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.48, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.42, + "temperatureApparent": 31.3, + "temperatureDewPoint": 22.14, + "uvIndex": 5, + "visibility": 25254.0, + "windDirection": 317, + "windGust": 19.62, + "windSpeed": 10.53 + }, + { + "forecastStart": "2023-09-09T06:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.71, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.9, + "temperatureApparent": 30.76, + "temperatureDewPoint": 22.2, + "uvIndex": 3, + "visibility": 23283.0, + "windDirection": 335, + "windGust": 18.98, + "windSpeed": 8.63 + }, + { + "forecastStart": "2023-09-09T07:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.76, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.12, + "temperatureApparent": 29.88, + "temperatureDewPoint": 22.17, + "uvIndex": 2, + "visibility": 24299.0, + "windDirection": 338, + "windGust": 17.04, + "windSpeed": 7.75 + }, + { + "forecastStart": "2023-09-09T08:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.38, + "temperatureApparent": 29.06, + "temperatureDewPoint": 22.15, + "uvIndex": 0, + "visibility": 21872.0, + "windDirection": 342, + "windGust": 14.75, + "windSpeed": 6.26 + }, + { + "forecastStart": "2023-09-09T09:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.38, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.37, + "temperatureApparent": 27.88, + "temperatureDewPoint": 21.99, + "uvIndex": 0, + "visibility": 19645.0, + "windDirection": 344, + "windGust": 10.43, + "windSpeed": 5.2 + }, + { + "forecastStart": "2023-09-09T10:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.73, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.53, + "temperatureApparent": 26.92, + "temperatureDewPoint": 21.88, + "uvIndex": 0, + "visibility": 20088.0, + "windDirection": 339, + "windGust": 6.95, + "windSpeed": 3.59 + }, + { + "forecastStart": "2023-09-09T11:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.3, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.07, + "temperatureApparent": 26.39, + "temperatureDewPoint": 21.81, + "uvIndex": 0, + "visibility": 17853.0, + "windDirection": 326, + "windGust": 5.27, + "windSpeed": 2.1 + }, + { + "forecastStart": "2023-09-09T12:00:00Z", + "cloudCover": 0.53, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.52, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.87, + "temperatureApparent": 26.15, + "temperatureDewPoint": 21.76, + "uvIndex": 0, + "visibility": 15352.0, + "windDirection": 257, + "windGust": 5.48, + "windSpeed": 0.93 + }, + { + "forecastStart": "2023-09-09T13:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.53, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.53, + "temperatureApparent": 25.79, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 16260.0, + "windDirection": 188, + "windGust": 4.44, + "windSpeed": 1.79 + }, + { + "forecastStart": "2023-09-09T14:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.46, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.11, + "temperatureApparent": 25.29, + "temperatureDewPoint": 21.67, + "uvIndex": 0, + "visibility": 17443.0, + "windDirection": 183, + "windGust": 4.49, + "windSpeed": 2.19 + }, + { + "forecastStart": "2023-09-09T15:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.21, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.59, + "temperatureApparent": 24.62, + "temperatureDewPoint": 21.36, + "uvIndex": 0, + "visibility": 17538.0, + "windDirection": 179, + "windGust": 5.32, + "windSpeed": 2.65 + }, + { + "forecastStart": "2023-09-09T16:00:00Z", + "cloudCover": 0.42, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.09, + "temperatureApparent": 23.98, + "temperatureDewPoint": 21.08, + "uvIndex": 0, + "visibility": 18544.0, + "windDirection": 173, + "windGust": 5.81, + "windSpeed": 3.2 + }, + { + "forecastStart": "2023-09-09T17:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.88, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.85, + "temperatureApparent": 23.66, + "temperatureDewPoint": 20.91, + "uvIndex": 0, + "visibility": 15814.0, + "windDirection": 159, + "windGust": 5.53, + "windSpeed": 3.16 + }, + { + "forecastStart": "2023-09-09T18:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.94, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.62, + "temperatureApparent": 23.34, + "temperatureDewPoint": 20.68, + "uvIndex": 0, + "visibility": 13955.0, + "windDirection": 153, + "windGust": 6.09, + "windSpeed": 3.36 + }, + { + "forecastStart": "2023-09-09T19:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.96, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.42, + "temperatureApparent": 23.06, + "temperatureDewPoint": 20.48, + "uvIndex": 0, + "visibility": 13042.0, + "windDirection": 150, + "windGust": 6.83, + "windSpeed": 3.71 + }, + { + "forecastStart": "2023-09-09T20:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.04, + "temperatureApparent": 22.52, + "temperatureDewPoint": 20.04, + "uvIndex": 0, + "visibility": 13016.0, + "windDirection": 156, + "windGust": 7.98, + "windSpeed": 4.27 + }, + { + "forecastStart": "2023-09-09T21:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.61, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.25, + "temperatureApparent": 22.78, + "temperatureDewPoint": 20.18, + "uvIndex": 0, + "visibility": 13648.0, + "windDirection": 156, + "windGust": 8.4, + "windSpeed": 4.69 + }, + { + "forecastStart": "2023-09-09T22:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.87, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.06, + "temperatureApparent": 25.08, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 20589.0, + "windDirection": 150, + "windGust": 7.66, + "windSpeed": 4.33 + }, + { + "forecastStart": "2023-09-09T23:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.64, + "temperatureApparent": 28.29, + "temperatureDewPoint": 22.26, + "uvIndex": 2, + "visibility": 24505.0, + "windDirection": 123, + "windGust": 9.63, + "windSpeed": 3.91 + }, + { + "forecastStart": "2023-09-10T00:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.42, + "temperatureApparent": 30.44, + "temperatureDewPoint": 22.64, + "uvIndex": 4, + "visibility": 25988.0, + "windDirection": 105, + "windGust": 12.59, + "windSpeed": 3.96 + }, + { + "forecastStart": "2023-09-10T01:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.79, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.88, + "temperatureApparent": 32.23, + "temperatureDewPoint": 22.95, + "uvIndex": 5, + "visibility": 26343.0, + "windDirection": 99, + "windGust": 14.17, + "windSpeed": 4.06 + }, + { + "forecastStart": "2023-09-10T02:00:00Z", + "cloudCover": 0.62, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.07, + "precipitationType": "rain", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.89, + "temperatureApparent": 33.37, + "temperatureDewPoint": 22.95, + "uvIndex": 6, + "visibility": 20305.0, + "windDirection": 93, + "windGust": 17.75, + "windSpeed": 4.87 + }, + { + "forecastStart": "2023-09-10T03:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.11, + "precipitationType": "rain", + "pressure": 1010.78, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.63, + "temperatureApparent": 34.32, + "temperatureDewPoint": 23.15, + "uvIndex": 6, + "visibility": 21524.0, + "windDirection": 78, + "windGust": 17.43, + "windSpeed": 4.54 + }, + { + "forecastStart": "2023-09-10T04:00:00Z", + "cloudCover": 0.74, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.37, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.32, + "temperatureApparent": 33.97, + "temperatureDewPoint": 23.16, + "uvIndex": 5, + "visibility": 19608.0, + "windDirection": 60, + "windGust": 15.24, + "windSpeed": 4.9 + }, + { + "forecastStart": "2023-09-10T05:00:00Z", + "cloudCover": 0.79, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.17, + "precipitationType": "rain", + "pressure": 1010.09, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.01, + "temperatureApparent": 33.68, + "temperatureDewPoint": 23.26, + "uvIndex": 4, + "visibility": 19170.0, + "windDirection": 80, + "windGust": 13.53, + "windSpeed": 5.98 + }, + { + "forecastStart": "2023-09-10T06:00:00Z", + "cloudCover": 0.8, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 1.0, + "precipitationIntensity": 1.0, + "precipitationChance": 0.17, + "precipitationType": "rain", + "pressure": 1010.0, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.51, + "temperatureApparent": 33.17, + "temperatureDewPoint": 23.37, + "uvIndex": 3, + "visibility": 20385.0, + "windDirection": 83, + "windGust": 12.55, + "windSpeed": 6.84 + }, + { + "forecastStart": "2023-09-10T07:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.16, + "precipitationType": "rain", + "pressure": 1010.27, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.73, + "temperatureApparent": 32.28, + "temperatureDewPoint": 23.36, + "uvIndex": 2, + "visibility": 21033.0, + "windDirection": 90, + "windGust": 10.16, + "windSpeed": 6.07 + }, + { + "forecastStart": "2023-09-10T08:00:00Z", + "cloudCover": 0.92, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.77, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1010.71, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.6, + "temperatureApparent": 30.9, + "temperatureDewPoint": 23.16, + "uvIndex": 0, + "visibility": 19490.0, + "windDirection": 101, + "windGust": 8.18, + "windSpeed": 4.82 + }, + { + "forecastStart": "2023-09-10T09:00:00Z", + "cloudCover": 0.93, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.9, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.52, + "temperatureApparent": 29.7, + "temperatureDewPoint": 23.2, + "uvIndex": 0, + "visibility": 15809.0, + "windDirection": 128, + "windGust": 8.89, + "windSpeed": 4.95 + }, + { + "forecastStart": "2023-09-10T10:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.12, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.61, + "temperatureApparent": 28.6, + "temperatureDewPoint": 23.02, + "uvIndex": 0, + "visibility": 16975.0, + "windDirection": 134, + "windGust": 10.03, + "windSpeed": 4.52 + }, + { + "forecastStart": "2023-09-10T11:00:00Z", + "cloudCover": 0.87, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.43, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.06, + "temperatureApparent": 27.88, + "temperatureDewPoint": 22.78, + "uvIndex": 0, + "visibility": 17463.0, + "windDirection": 137, + "windGust": 12.4, + "windSpeed": 5.41 + }, + { + "forecastStart": "2023-09-10T12:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.58, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.78, + "temperatureApparent": 27.45, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 18599.0, + "windDirection": 143, + "windGust": 16.36, + "windSpeed": 6.31 + }, + { + "forecastStart": "2023-09-10T13:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.52, + "temperatureApparent": 27.12, + "temperatureDewPoint": 22.4, + "uvIndex": 0, + "visibility": 19560.0, + "windDirection": 144, + "windGust": 19.66, + "windSpeed": 7.23 + }, + { + "forecastStart": "2023-09-10T14:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.4, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.29, + "temperatureApparent": 26.81, + "temperatureDewPoint": 22.25, + "uvIndex": 0, + "visibility": 20164.0, + "windDirection": 141, + "windGust": 21.15, + "windSpeed": 7.46 + }, + { + "forecastStart": "2023-09-10T15:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.95, + "temperatureApparent": 26.33, + "temperatureDewPoint": 21.99, + "uvIndex": 0, + "visibility": 20723.0, + "windDirection": 141, + "windGust": 22.26, + "windSpeed": 7.84 + }, + { + "forecastStart": "2023-09-10T16:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.77, + "temperatureApparent": 26.06, + "temperatureDewPoint": 21.81, + "uvIndex": 0, + "visibility": 20584.0, + "windDirection": 144, + "windGust": 23.53, + "windSpeed": 8.63 + }, + { + "forecastStart": "2023-09-10T17:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.78, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.47, + "temperatureApparent": 25.65, + "temperatureDewPoint": 21.59, + "uvIndex": 0, + "visibility": 21559.0, + "windDirection": 144, + "windGust": 22.83, + "windSpeed": 8.61 + }, + { + "forecastStart": "2023-09-10T18:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.69, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.28, + "temperatureApparent": 25.4, + "temperatureDewPoint": 21.47, + "uvIndex": 0, + "visibility": 20210.0, + "windDirection": 143, + "windGust": 23.7, + "windSpeed": 8.7 + }, + { + "forecastStart": "2023-09-10T19:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.77, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.14, + "temperatureApparent": 25.23, + "temperatureDewPoint": 21.41, + "uvIndex": 0, + "visibility": 20532.0, + "windDirection": 140, + "windGust": 24.24, + "windSpeed": 8.74 + }, + { + "forecastStart": "2023-09-10T20:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.33, + "temperatureApparent": 25.5, + "temperatureDewPoint": 21.6, + "uvIndex": 0, + "visibility": 21210.0, + "windDirection": 138, + "windGust": 23.99, + "windSpeed": 8.81 + }, + { + "forecastStart": "2023-09-10T21:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.1, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.67, + "temperatureApparent": 25.86, + "temperatureDewPoint": 21.56, + "uvIndex": 0, + "visibility": 22103.0, + "windDirection": 138, + "windGust": 25.55, + "windSpeed": 9.05 + }, + { + "forecastStart": "2023-09-10T22:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.61, + "temperatureApparent": 26.97, + "temperatureDewPoint": 21.8, + "uvIndex": 1, + "visibility": 22607.0, + "windDirection": 140, + "windGust": 29.08, + "windSpeed": 10.37 + }, + { + "forecastStart": "2023-09-10T23:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.36, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.85, + "temperatureApparent": 28.36, + "temperatureDewPoint": 21.89, + "uvIndex": 2, + "visibility": 23231.0, + "windDirection": 140, + "windGust": 34.13, + "windSpeed": 12.56 + }, + { + "forecastStart": "2023-09-11T00:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.25, + "temperatureApparent": 30.09, + "temperatureDewPoint": 22.3, + "uvIndex": 3, + "visibility": 24284.0, + "windDirection": 140, + "windGust": 38.2, + "windSpeed": 15.65 + }, + { + "forecastStart": "2023-09-11T01:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.39, + "temperatureApparent": 31.35, + "temperatureDewPoint": 22.3, + "uvIndex": 5, + "visibility": 24490.0, + "windDirection": 141, + "windGust": 37.55, + "windSpeed": 15.78 + }, + { + "forecastStart": "2023-09-11T02:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.55, + "temperatureApparent": 32.71, + "temperatureDewPoint": 22.43, + "uvIndex": 6, + "visibility": 23811.0, + "windDirection": 143, + "windGust": 35.86, + "windSpeed": 15.41 + }, + { + "forecastStart": "2023-09-11T03:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.61, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.27, + "temperatureApparent": 33.55, + "temperatureDewPoint": 22.5, + "uvIndex": 6, + "visibility": 20414.0, + "windDirection": 141, + "windGust": 35.88, + "windSpeed": 15.51 + }, + { + "forecastStart": "2023-09-11T04:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.36, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.43, + "temperatureApparent": 33.81, + "temperatureDewPoint": 22.65, + "uvIndex": 5, + "visibility": 19760.0, + "windDirection": 140, + "windGust": 35.99, + "windSpeed": 15.75 + }, + { + "forecastStart": "2023-09-11T05:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.11, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.15, + "temperatureApparent": 33.47, + "temperatureDewPoint": 22.59, + "uvIndex": 4, + "visibility": 24662.0, + "windDirection": 137, + "windGust": 33.61, + "windSpeed": 15.36 + }, + { + "forecastStart": "2023-09-11T06:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.97, + "temperatureApparent": 33.23, + "temperatureDewPoint": 22.52, + "uvIndex": 3, + "visibility": 26577.0, + "windDirection": 138, + "windGust": 32.61, + "windSpeed": 14.98 + }, + { + "forecastStart": "2023-09-11T07:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.13, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.25, + "temperatureApparent": 32.28, + "temperatureDewPoint": 22.24, + "uvIndex": 2, + "visibility": 24239.0, + "windDirection": 138, + "windGust": 28.1, + "windSpeed": 13.88 + }, + { + "forecastStart": "2023-09-11T08:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.48, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.32, + "temperatureApparent": 31.19, + "temperatureDewPoint": 22.14, + "uvIndex": 0, + "visibility": 25056.0, + "windDirection": 137, + "windGust": 24.22, + "windSpeed": 13.02 + }, + { + "forecastStart": "2023-09-11T09:00:00Z", + "cloudCover": 0.55, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.81, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.15, + "temperatureApparent": 29.77, + "temperatureDewPoint": 21.85, + "uvIndex": 0, + "visibility": 23658.0, + "windDirection": 138, + "windGust": 22.5, + "windSpeed": 11.94 + }, + { + "forecastStart": "2023-09-11T10:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.29, + "temperatureApparent": 28.77, + "temperatureDewPoint": 21.72, + "uvIndex": 0, + "visibility": 23317.0, + "windDirection": 137, + "windGust": 21.47, + "windSpeed": 11.25 + }, + { + "forecastStart": "2023-09-11T11:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.77, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.62, + "temperatureApparent": 28.09, + "temperatureDewPoint": 21.83, + "uvIndex": 0, + "visibility": 21978.0, + "windDirection": 141, + "windGust": 22.71, + "windSpeed": 12.39 + }, + { + "forecastStart": "2023-09-11T12:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.97, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.16, + "temperatureApparent": 27.57, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 20260.0, + "windDirection": 143, + "windGust": 23.67, + "windSpeed": 12.83 + }, + { + "forecastStart": "2023-09-11T13:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.97, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.74, + "temperatureApparent": 27.07, + "temperatureDewPoint": 21.7, + "uvIndex": 0, + "visibility": 18240.0, + "windDirection": 146, + "windGust": 23.34, + "windSpeed": 12.62 + }, + { + "forecastStart": "2023-09-11T14:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.83, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.41, + "temperatureApparent": 26.71, + "temperatureDewPoint": 21.68, + "uvIndex": 0, + "visibility": 18444.0, + "windDirection": 147, + "windGust": 22.9, + "windSpeed": 12.07 + }, + { + "forecastStart": "2023-09-11T15:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.06, + "temperatureApparent": 26.31, + "temperatureDewPoint": 21.65, + "uvIndex": 0, + "visibility": 20008.0, + "windDirection": 147, + "windGust": 22.01, + "windSpeed": 11.19 + }, + { + "forecastStart": "2023-09-11T16:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.56, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.73, + "temperatureApparent": 25.92, + "temperatureDewPoint": 21.55, + "uvIndex": 0, + "visibility": 19191.0, + "windDirection": 149, + "windGust": 21.29, + "windSpeed": 10.97 + }, + { + "forecastStart": "2023-09-11T17:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.35, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.64, + "temperatureApparent": 25.79, + "temperatureDewPoint": 21.46, + "uvIndex": 0, + "visibility": 19549.0, + "windDirection": 150, + "windGust": 20.52, + "windSpeed": 10.5 + }, + { + "forecastStart": "2023-09-11T18:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.3, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.54, + "temperatureApparent": 25.67, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 19709.0, + "windDirection": 149, + "windGust": 20.04, + "windSpeed": 10.51 + }, + { + "forecastStart": "2023-09-11T19:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.35, + "temperatureApparent": 25.42, + "temperatureDewPoint": 21.32, + "uvIndex": 0, + "visibility": 17439.0, + "windDirection": 146, + "windGust": 18.07, + "windSpeed": 10.13 + }, + { + "forecastStart": "2023-09-11T20:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.13, + "precipitationType": "rain", + "pressure": 1011.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.15, + "temperatureApparent": 25.16, + "temperatureDewPoint": 21.2, + "uvIndex": 0, + "visibility": 15297.0, + "windDirection": 141, + "windGust": 16.86, + "windSpeed": 10.34 + }, + { + "forecastStart": "2023-09-11T21:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.71, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.43, + "temperatureApparent": 25.54, + "temperatureDewPoint": 21.4, + "uvIndex": 0, + "visibility": 17935.0, + "windDirection": 138, + "windGust": 16.66, + "windSpeed": 10.68 + }, + { + "forecastStart": "2023-09-11T22:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.94, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.45, + "temperatureApparent": 26.83, + "temperatureDewPoint": 21.88, + "uvIndex": 1, + "visibility": 17153.0, + "windDirection": 137, + "windGust": 17.21, + "windSpeed": 10.61 + }, + { + "forecastStart": "2023-09-11T23:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.55, + "temperatureApparent": 28.22, + "temperatureDewPoint": 22.33, + "uvIndex": 2, + "visibility": 19126.0, + "windDirection": 138, + "windGust": 19.23, + "windSpeed": 11.13 + }, + { + "forecastStart": "2023-09-12T00:00:00Z", + "cloudCover": 0.79, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.61, + "temperatureApparent": 29.53, + "temperatureDewPoint": 22.63, + "uvIndex": 3, + "visibility": 16639.0, + "windDirection": 140, + "windGust": 20.61, + "windSpeed": 11.13 + }, + { + "forecastStart": "2023-09-12T01:00:00Z", + "cloudCover": 0.82, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.16, + "precipitationType": "rain", + "pressure": 1011.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.92, + "temperatureApparent": 31.24, + "temperatureDewPoint": 23.12, + "uvIndex": 4, + "visibility": 16716.0, + "windDirection": 141, + "windGust": 23.35, + "windSpeed": 11.98 + }, + { + "forecastStart": "2023-09-12T02:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.97, + "temperatureApparent": 32.63, + "temperatureDewPoint": 23.5, + "uvIndex": 5, + "visibility": 19639.0, + "windDirection": 143, + "windGust": 26.45, + "windSpeed": 13.01 + }, + { + "forecastStart": "2023-09-12T03:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.15, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.76, + "temperatureApparent": 33.53, + "temperatureDewPoint": 23.51, + "uvIndex": 5, + "visibility": 23538.0, + "windDirection": 141, + "windGust": 28.95, + "windSpeed": 13.9 + }, + { + "forecastStart": "2023-09-12T04:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.79, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.21, + "temperatureApparent": 34.01, + "temperatureDewPoint": 23.45, + "uvIndex": 5, + "visibility": 24964.0, + "windDirection": 141, + "windGust": 27.9, + "windSpeed": 13.95 + }, + { + "forecastStart": "2023-09-12T05:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.43, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.42, + "temperatureApparent": 34.02, + "temperatureDewPoint": 23.05, + "uvIndex": 4, + "visibility": 26399.0, + "windDirection": 140, + "windGust": 26.53, + "windSpeed": 13.78 + }, + { + "forecastStart": "2023-09-12T06:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.21, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.07, + "temperatureApparent": 33.39, + "temperatureDewPoint": 22.62, + "uvIndex": 3, + "visibility": 27308.0, + "windDirection": 138, + "windGust": 24.56, + "windSpeed": 13.74 + }, + { + "forecastStart": "2023-09-12T07:00:00Z", + "cloudCover": 0.53, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.26, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.06, + "temperatureApparent": 31.98, + "temperatureDewPoint": 22.06, + "uvIndex": 2, + "visibility": 27514.0, + "windDirection": 138, + "windGust": 22.78, + "windSpeed": 13.21 + }, + { + "forecastStart": "2023-09-12T08:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.51, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.14, + "temperatureApparent": 30.87, + "temperatureDewPoint": 21.87, + "uvIndex": 0, + "visibility": 27191.0, + "windDirection": 140, + "windGust": 19.92, + "windSpeed": 12.0 + }, + { + "forecastStart": "2023-09-12T09:00:00Z", + "cloudCover": 0.5, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.8, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.18, + "temperatureApparent": 29.73, + "temperatureDewPoint": 21.69, + "uvIndex": 0, + "visibility": 26334.0, + "windDirection": 141, + "windGust": 17.65, + "windSpeed": 10.97 + }, + { + "forecastStart": "2023-09-12T10:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.23, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.19, + "temperatureApparent": 28.55, + "temperatureDewPoint": 21.45, + "uvIndex": 0, + "visibility": 24588.0, + "windDirection": 143, + "windGust": 15.87, + "windSpeed": 10.23 + }, + { + "forecastStart": "2023-09-12T11:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1011.79, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.36, + "temperatureApparent": 27.6, + "temperatureDewPoint": 21.33, + "uvIndex": 0, + "visibility": 22303.0, + "windDirection": 146, + "windGust": 13.9, + "windSpeed": 9.39 + }, + { + "forecastStart": "2023-09-12T12:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.81, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.47, + "precipitationType": "clear", + "pressure": 1012.12, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.68, + "temperatureApparent": 26.82, + "temperatureDewPoint": 21.24, + "uvIndex": 0, + "visibility": 20535.0, + "windDirection": 147, + "windGust": 13.32, + "windSpeed": 8.9 + }, + { + "forecastStart": "2023-09-12T13:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1012.18, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.23, + "temperatureApparent": 26.32, + "temperatureDewPoint": 21.2, + "uvIndex": 0, + "visibility": 19800.0, + "windDirection": 149, + "windGust": 13.18, + "windSpeed": 8.59 + }, + { + "forecastStart": "2023-09-12T14:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.91, + "temperatureApparent": 26.0, + "temperatureDewPoint": 21.27, + "uvIndex": 0, + "visibility": 19587.0, + "windDirection": 149, + "windGust": 13.84, + "windSpeed": 8.87 + }, + { + "forecastStart": "2023-09-12T15:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.61, + "temperatureApparent": 25.68, + "temperatureDewPoint": 21.28, + "uvIndex": 0, + "visibility": 19418.0, + "windDirection": 149, + "windGust": 15.08, + "windSpeed": 8.93 + }, + { + "forecastStart": "2023-09-12T16:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.18, + "temperatureApparent": 25.12, + "temperatureDewPoint": 21.01, + "uvIndex": 0, + "visibility": 19187.0, + "windDirection": 146, + "windGust": 16.74, + "windSpeed": 9.49 + }, + { + "forecastStart": "2023-09-12T17:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.75, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.86, + "temperatureApparent": 24.72, + "temperatureDewPoint": 20.84, + "uvIndex": 0, + "visibility": 19001.0, + "windDirection": 146, + "windGust": 17.45, + "windSpeed": 9.12 + }, + { + "forecastStart": "2023-09-12T18:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.77, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.62, + "temperatureApparent": 24.41, + "temperatureDewPoint": 20.68, + "uvIndex": 0, + "visibility": 18698.0, + "windDirection": 149, + "windGust": 17.04, + "windSpeed": 8.68 + }, + { + "forecastStart": "2023-09-12T19:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.37, + "temperatureApparent": 24.1, + "temperatureDewPoint": 20.58, + "uvIndex": 0, + "visibility": 17831.0, + "windDirection": 149, + "windGust": 16.8, + "windSpeed": 8.61 + }, + { + "forecastStart": "2023-09-12T20:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.15, + "temperatureApparent": 23.85, + "temperatureDewPoint": 20.5, + "uvIndex": 0, + "visibility": 16846.0, + "windDirection": 150, + "windGust": 15.35, + "windSpeed": 8.36 + }, + { + "forecastStart": "2023-09-12T21:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.49, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.59, + "temperatureApparent": 24.36, + "temperatureDewPoint": 20.65, + "uvIndex": 0, + "visibility": 16919.0, + "windDirection": 155, + "windGust": 14.09, + "windSpeed": 7.77 + }, + { + "forecastStart": "2023-09-12T22:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.72, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.82, + "temperatureApparent": 25.82, + "temperatureDewPoint": 21.03, + "uvIndex": 1, + "visibility": 19326.0, + "windDirection": 152, + "windGust": 14.04, + "windSpeed": 7.25 + }, + { + "forecastStart": "2023-09-12T23:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.85, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.5, + "temperatureApparent": 27.77, + "temperatureDewPoint": 21.38, + "uvIndex": 2, + "visibility": 22800.0, + "windDirection": 149, + "windGust": 15.31, + "windSpeed": 7.14 + }, + { + "forecastStart": "2023-09-13T00:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.13, + "temperatureApparent": 29.74, + "temperatureDewPoint": 21.83, + "uvIndex": 4, + "visibility": 24706.0, + "windDirection": 141, + "windGust": 16.42, + "windSpeed": 6.89 + }, + { + "forecastStart": "2023-09-13T01:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.65, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.44, + "temperatureApparent": 31.24, + "temperatureDewPoint": 21.96, + "uvIndex": 5, + "visibility": 23309.0, + "windDirection": 137, + "windGust": 18.64, + "windSpeed": 6.65 + }, + { + "forecastStart": "2023-09-13T02:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.26, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.41, + "temperatureApparent": 32.28, + "temperatureDewPoint": 21.89, + "uvIndex": 5, + "visibility": 20329.0, + "windDirection": 128, + "windGust": 21.69, + "windSpeed": 7.12 + }, + { + "forecastStart": "2023-09-13T03:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.62, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.88, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.06, + "temperatureApparent": 33.0, + "temperatureDewPoint": 21.88, + "uvIndex": 6, + "visibility": 17382.0, + "windDirection": 111, + "windGust": 23.41, + "windSpeed": 7.33 + }, + { + "forecastStart": "2023-09-13T04:00:00Z", + "cloudCover": 0.72, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.61, + "precipitationAmount": 0.9, + "precipitationIntensity": 0.9, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.55, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.4, + "temperatureApparent": 33.43, + "temperatureDewPoint": 21.98, + "uvIndex": 5, + "visibility": 18579.0, + "windDirection": 56, + "windGust": 23.1, + "windSpeed": 8.09 + }, + { + "forecastStart": "2023-09-13T05:00:00Z", + "cloudCover": 0.72, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.61, + "precipitationAmount": 1.9, + "precipitationIntensity": 1.9, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.2, + "temperatureApparent": 33.16, + "temperatureDewPoint": 21.9, + "uvIndex": 4, + "visibility": 18850.0, + "windDirection": 20, + "windGust": 21.81, + "windSpeed": 9.46 + }, + { + "forecastStart": "2023-09-13T06:00:00Z", + "cloudCover": 0.74, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 2.3, + "precipitationIntensity": 2.3, + "precipitationChance": 0.11, + "precipitationType": "rain", + "pressure": 1011.17, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.67, + "temperatureApparent": 32.59, + "temperatureDewPoint": 21.93, + "uvIndex": 3, + "visibility": 20634.0, + "windDirection": 20, + "windGust": 19.72, + "windSpeed": 9.8 + }, + { + "forecastStart": "2023-09-13T07:00:00Z", + "cloudCover": 0.69, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 1.8, + "precipitationIntensity": 1.8, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1011.32, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.77, + "temperatureApparent": 31.81, + "temperatureDewPoint": 22.37, + "uvIndex": 1, + "visibility": 19468.0, + "windDirection": 18, + "windGust": 17.55, + "windSpeed": 9.23 + }, + { + "forecastStart": "2023-09-13T08:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.8, + "precipitationIntensity": 0.8, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1011.6, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.61, + "temperatureApparent": 30.78, + "temperatureDewPoint": 22.91, + "uvIndex": 0, + "visibility": 18451.0, + "windDirection": 27, + "windGust": 15.08, + "windSpeed": 8.05 + }, + { + "forecastStart": "2023-09-13T09:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.94, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.33, + "temperatureApparent": 29.4, + "temperatureDewPoint": 23.01, + "uvIndex": 0, + "visibility": 19184.0, + "windDirection": 32, + "windGust": 12.17, + "windSpeed": 6.68 + }, + { + "forecastStart": "2023-09-13T10:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.3, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.54, + "temperatureApparent": 28.46, + "temperatureDewPoint": 22.87, + "uvIndex": 0, + "visibility": 17878.0, + "windDirection": 69, + "windGust": 11.64, + "windSpeed": 6.69 + }, + { + "forecastStart": "2023-09-13T11:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.71, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.98, + "temperatureApparent": 27.73, + "temperatureDewPoint": 22.63, + "uvIndex": 0, + "visibility": 19357.0, + "windDirection": 155, + "windGust": 11.91, + "windSpeed": 6.23 + }, + { + "forecastStart": "2023-09-13T12:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.96, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.53, + "temperatureApparent": 27.11, + "temperatureDewPoint": 22.34, + "uvIndex": 0, + "visibility": 19658.0, + "windDirection": 161, + "windGust": 12.47, + "windSpeed": 5.73 + }, + { + "forecastStart": "2023-09-13T13:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.03, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.17, + "temperatureApparent": 26.69, + "temperatureDewPoint": 22.28, + "uvIndex": 0, + "visibility": 20272.0, + "windDirection": 161, + "windGust": 13.57, + "windSpeed": 5.66 + }, + { + "forecastStart": "2023-09-13T14:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.91, + "temperatureApparent": 26.36, + "temperatureDewPoint": 22.17, + "uvIndex": 0, + "visibility": 20994.0, + "windDirection": 159, + "windGust": 15.07, + "windSpeed": 5.83 + }, + { + "forecastStart": "2023-09-13T15:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.69, + "temperatureApparent": 26.12, + "temperatureDewPoint": 22.17, + "uvIndex": 0, + "visibility": 21105.0, + "windDirection": 158, + "windGust": 16.06, + "windSpeed": 5.93 + }, + { + "forecastStart": "2023-09-13T16:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.9, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.35, + "temperatureApparent": 25.67, + "temperatureDewPoint": 21.98, + "uvIndex": 0, + "visibility": 20061.0, + "windDirection": 153, + "windGust": 16.05, + "windSpeed": 5.75 + }, + { + "forecastStart": "2023-09-13T17:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.85, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.14, + "temperatureApparent": 25.39, + "temperatureDewPoint": 21.84, + "uvIndex": 0, + "visibility": 18402.0, + "windDirection": 150, + "windGust": 15.52, + "windSpeed": 5.49 + }, + { + "forecastStart": "2023-09-13T18:00:00Z", + "cloudCover": 0.92, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.87, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.99, + "temperatureApparent": 25.2, + "temperatureDewPoint": 21.76, + "uvIndex": 0, + "visibility": 17039.0, + "windDirection": 149, + "windGust": 15.01, + "windSpeed": 5.32 + }, + { + "forecastStart": "2023-09-13T19:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.79, + "temperatureApparent": 24.96, + "temperatureDewPoint": 21.7, + "uvIndex": 0, + "visibility": 16081.0, + "windDirection": 147, + "windGust": 14.39, + "windSpeed": 5.33 + }, + { + "forecastStart": "2023-09-13T20:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.22, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.63, + "temperatureApparent": 24.75, + "temperatureDewPoint": 21.61, + "uvIndex": 0, + "visibility": 15426.0, + "windDirection": 147, + "windGust": 13.79, + "windSpeed": 5.43 + }, + { + "forecastStart": "2023-09-13T21:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.1, + "temperatureApparent": 25.33, + "temperatureDewPoint": 21.8, + "uvIndex": 0, + "visibility": 15660.0, + "windDirection": 147, + "windGust": 14.12, + "windSpeed": 5.52 + }, + { + "forecastStart": "2023-09-13T22:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.59, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.26, + "temperatureApparent": 26.73, + "temperatureDewPoint": 22.14, + "uvIndex": 1, + "visibility": 17559.0, + "windDirection": 147, + "windGust": 16.14, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-13T23:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.67, + "temperatureApparent": 28.37, + "temperatureDewPoint": 22.37, + "uvIndex": 2, + "visibility": 20352.0, + "windDirection": 146, + "windGust": 19.09, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T00:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.78, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.37, + "temperatureApparent": 30.48, + "temperatureDewPoint": 22.85, + "uvIndex": 4, + "visibility": 22307.0, + "windDirection": 143, + "windGust": 21.6, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-14T01:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.61, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.73, + "temperatureApparent": 32.18, + "temperatureDewPoint": 23.18, + "uvIndex": 5, + "visibility": 22630.0, + "windDirection": 138, + "windGust": 23.36, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-14T02:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.32, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.87, + "temperatureApparent": 33.5, + "temperatureDewPoint": 23.23, + "uvIndex": 6, + "visibility": 22159.0, + "windDirection": 111, + "windGust": 24.72, + "windSpeed": 4.99 + }, + { + "forecastStart": "2023-09-14T03:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.04, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.66, + "temperatureApparent": 34.42, + "temperatureDewPoint": 23.28, + "uvIndex": 6, + "visibility": 21610.0, + "windDirection": 354, + "windGust": 25.23, + "windSpeed": 4.74 + }, + { + "forecastStart": "2023-09-14T04:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.77, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.98, + "temperatureApparent": 34.85, + "temperatureDewPoint": 23.37, + "uvIndex": 6, + "visibility": 21210.0, + "windDirection": 341, + "windGust": 24.6, + "windSpeed": 4.79 + }, + { + "forecastStart": "2023-09-14T05:00:00Z", + "cloudCover": 0.6, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1012.53, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.73, + "temperatureApparent": 34.48, + "temperatureDewPoint": 23.24, + "uvIndex": 5, + "visibility": 20870.0, + "windDirection": 336, + "windGust": 23.28, + "windSpeed": 5.07 + }, + { + "forecastStart": "2023-09-14T06:00:00Z", + "cloudCover": 0.59, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1012.49, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.23, + "temperatureApparent": 33.82, + "temperatureDewPoint": 23.07, + "uvIndex": 3, + "visibility": 20831.0, + "windDirection": 336, + "windGust": 22.05, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-14T07:00:00Z", + "cloudCover": 0.53, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.4, + "precipitationType": "rain", + "pressure": 1012.73, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.47, + "temperatureApparent": 32.94, + "temperatureDewPoint": 23.04, + "uvIndex": 2, + "visibility": 21284.0, + "windDirection": 339, + "windGust": 21.18, + "windSpeed": 5.63 + }, + { + "forecastStart": "2023-09-14T08:00:00Z", + "cloudCover": 0.43, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.45, + "precipitationType": "clear", + "pressure": 1013.16, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.35, + "temperatureApparent": 31.56, + "temperatureDewPoint": 22.82, + "uvIndex": 0, + "visibility": 21999.0, + "windDirection": 342, + "windGust": 20.35, + "windSpeed": 5.93 + }, + { + "forecastStart": "2023-09-14T09:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1013.62, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.11, + "temperatureApparent": 30.03, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 22578.0, + "windDirection": 347, + "windGust": 19.42, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-14T10:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.09, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.27, + "temperatureApparent": 29.04, + "temperatureDewPoint": 22.38, + "uvIndex": 0, + "visibility": 22916.0, + "windDirection": 348, + "windGust": 18.19, + "windSpeed": 5.31 + }, + { + "forecastStart": "2023-09-14T11:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.56, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.53, + "temperatureApparent": 28.23, + "temperatureDewPoint": 22.39, + "uvIndex": 0, + "visibility": 23051.0, + "windDirection": 177, + "windGust": 16.79, + "windSpeed": 4.28 + }, + { + "forecastStart": "2023-09-14T12:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.87, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.9, + "temperatureApparent": 27.51, + "temperatureDewPoint": 22.32, + "uvIndex": 0, + "visibility": 22814.0, + "windDirection": 171, + "windGust": 15.61, + "windSpeed": 3.72 + }, + { + "forecastStart": "2023-09-14T13:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.17, + "temperatureApparent": 26.6, + "temperatureDewPoint": 22.06, + "uvIndex": 0, + "visibility": 21946.0, + "windDirection": 171, + "windGust": 14.7, + "windSpeed": 4.11 + }, + { + "forecastStart": "2023-09-14T14:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.8, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.6, + "temperatureApparent": 25.9, + "temperatureDewPoint": 21.86, + "uvIndex": 0, + "visibility": 20560.0, + "windDirection": 171, + "windGust": 13.81, + "windSpeed": 4.97 + }, + { + "forecastStart": "2023-09-14T15:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.66, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.1, + "temperatureApparent": 25.28, + "temperatureDewPoint": 21.66, + "uvIndex": 0, + "visibility": 19040.0, + "windDirection": 170, + "windGust": 12.88, + "windSpeed": 5.57 + }, + { + "forecastStart": "2023-09-14T16:00:00Z", + "cloudCover": 0.37, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.69, + "temperatureApparent": 24.76, + "temperatureDewPoint": 21.46, + "uvIndex": 0, + "visibility": 17747.0, + "windDirection": 168, + "windGust": 12.0, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T17:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.45, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.4, + "temperatureApparent": 24.4, + "temperatureDewPoint": 21.32, + "uvIndex": 0, + "visibility": 16872.0, + "windDirection": 165, + "windGust": 11.43, + "windSpeed": 5.48 + }, + { + "forecastStart": "2023-09-14T18:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.44, + "precipitationType": "clear", + "pressure": 1014.45, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.58, + "temperatureApparent": 24.63, + "temperatureDewPoint": 21.43, + "uvIndex": 0, + "visibility": 16548.0, + "windDirection": 162, + "windGust": 11.42, + "windSpeed": 5.38 + }, + { + "forecastStart": "2023-09-14T19:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.52, + "precipitationType": "clear", + "pressure": 1014.63, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.88, + "temperatureApparent": 25.01, + "temperatureDewPoint": 21.58, + "uvIndex": 0, + "visibility": 16862.0, + "windDirection": 161, + "windGust": 12.15, + "windSpeed": 5.39 + }, + { + "forecastStart": "2023-09-14T20:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.51, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.36, + "temperatureApparent": 25.6, + "temperatureDewPoint": 21.77, + "uvIndex": 0, + "visibility": 17845.0, + "windDirection": 159, + "windGust": 13.54, + "windSpeed": 5.45 + }, + { + "forecastStart": "2023-09-14T21:00:00Z", + "cloudCover": 0.36, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.42, + "precipitationType": "clear", + "pressure": 1015.18, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.2, + "temperatureApparent": 26.61, + "temperatureDewPoint": 22.01, + "uvIndex": 0, + "visibility": 19537.0, + "windDirection": 158, + "windGust": 15.48, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T22:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.29, + "precipitationType": "clear", + "pressure": 1015.4, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.68, + "temperatureApparent": 28.46, + "temperatureDewPoint": 22.54, + "uvIndex": 1, + "visibility": 21828.0, + "windDirection": 158, + "windGust": 17.86, + "windSpeed": 5.84 + }, + { + "forecastStart": "2023-09-14T23:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.77, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.19, + "temperatureApparent": 30.28, + "temperatureDewPoint": 22.85, + "uvIndex": 2, + "visibility": 24036.0, + "windDirection": 155, + "windGust": 20.19, + "windSpeed": 6.09 + }, + { + "forecastStart": "2023-09-15T00:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.65, + "temperatureApparent": 32.15, + "temperatureDewPoint": 23.29, + "uvIndex": 4, + "visibility": 25340.0, + "windDirection": 152, + "windGust": 21.83, + "windSpeed": 6.42 + }, + { + "forecastStart": "2023-09-15T01:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.35, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.65, + "temperatureApparent": 33.4, + "temperatureDewPoint": 23.5, + "uvIndex": 6, + "visibility": 25384.0, + "windDirection": 144, + "windGust": 22.56, + "windSpeed": 6.91 + }, + { + "forecastStart": "2023-09-15T02:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.0, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.38, + "temperatureApparent": 34.24, + "temperatureDewPoint": 23.52, + "uvIndex": 7, + "visibility": 24635.0, + "windDirection": 336, + "windGust": 22.83, + "windSpeed": 7.47 + }, + { + "forecastStart": "2023-09-15T03:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.62, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.93, + "temperatureApparent": 34.88, + "temperatureDewPoint": 23.53, + "uvIndex": 7, + "visibility": 23513.0, + "windDirection": 336, + "windGust": 22.98, + "windSpeed": 7.95 + }, + { + "forecastStart": "2023-09-15T04:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.25, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.31, + "temperatureApparent": 35.35, + "temperatureDewPoint": 23.58, + "uvIndex": 6, + "visibility": 22350.0, + "windDirection": 341, + "windGust": 23.21, + "windSpeed": 8.44 + }, + { + "forecastStart": "2023-09-15T05:00:00Z", + "cloudCover": 0.44, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.95, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.46, + "temperatureApparent": 35.61, + "temperatureDewPoint": 23.72, + "uvIndex": 5, + "visibility": 21383.0, + "windDirection": 344, + "windGust": 23.46, + "windSpeed": 8.95 + }, + { + "forecastStart": "2023-09-15T06:00:00Z", + "cloudCover": 0.42, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.83, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.09, + "temperatureApparent": 35.1, + "temperatureDewPoint": 23.58, + "uvIndex": 3, + "visibility": 20900.0, + "windDirection": 347, + "windGust": 23.64, + "windSpeed": 9.13 + }, + { + "forecastStart": "2023-09-15T07:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.96, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.33, + "temperatureApparent": 34.1, + "temperatureDewPoint": 23.37, + "uvIndex": 2, + "visibility": 21046.0, + "windDirection": 350, + "windGust": 23.66, + "windSpeed": 8.78 + }, + { + "forecastStart": "2023-09-15T08:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.25, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.98, + "temperatureApparent": 32.39, + "temperatureDewPoint": 23.05, + "uvIndex": 0, + "visibility": 21562.0, + "windDirection": 356, + "windGust": 23.51, + "windSpeed": 8.13 + }, + { + "forecastStart": "2023-09-15T09:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.61, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.94, + "temperatureApparent": 31.13, + "temperatureDewPoint": 22.87, + "uvIndex": 0, + "visibility": 22131.0, + "windDirection": 3, + "windGust": 23.21, + "windSpeed": 7.48 + }, + { + "forecastStart": "2023-09-15T10:00:00Z", + "cloudCover": 0.43, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.02, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.95, + "temperatureApparent": 29.98, + "temperatureDewPoint": 22.79, + "uvIndex": 0, + "visibility": 22382.0, + "windDirection": 20, + "windGust": 22.68, + "windSpeed": 6.83 + }, + { + "forecastStart": "2023-09-15T11:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.43, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.21, + "temperatureApparent": 29.17, + "temperatureDewPoint": 22.81, + "uvIndex": 0, + "visibility": 22366.0, + "windDirection": 129, + "windGust": 22.04, + "windSpeed": 6.1 + }, + { + "forecastStart": "2023-09-15T12:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.71, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.56, + "temperatureApparent": 28.42, + "temperatureDewPoint": 22.73, + "uvIndex": 0, + "visibility": 22383.0, + "windDirection": 159, + "windGust": 21.64, + "windSpeed": 5.6 + }, + { + "forecastStart": "2023-09-15T13:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.52, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.22, + "temperatureApparent": 28.24, + "temperatureDewPoint": 23.16, + "uvIndex": 0, + "visibility": 21966.0, + "windDirection": 164, + "windGust": 16.35, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-15T14:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.61, + "temperatureApparent": 27.42, + "temperatureDewPoint": 22.86, + "uvIndex": 0, + "visibility": 22357.0, + "windDirection": 168, + "windGust": 17.11, + "windSpeed": 5.79 + }, + { + "forecastStart": "2023-09-15T15:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.21, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.16, + "temperatureApparent": 26.86, + "temperatureDewPoint": 22.71, + "uvIndex": 0, + "visibility": 22189.0, + "windDirection": 182, + "windGust": 17.32, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-15T16:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.78, + "temperatureApparent": 26.4, + "temperatureDewPoint": 22.61, + "uvIndex": 0, + "visibility": 21374.0, + "windDirection": 201, + "windGust": 16.6, + "windSpeed": 5.27 + }, + { + "forecastStart": "2023-09-15T17:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.48, + "temperatureApparent": 26.01, + "temperatureDewPoint": 22.46, + "uvIndex": 0, + "visibility": 20612.0, + "windDirection": 219, + "windGust": 15.52, + "windSpeed": 4.62 + }, + { + "forecastStart": "2023-09-15T18:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.88, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.29, + "temperatureApparent": 25.72, + "temperatureDewPoint": 22.27, + "uvIndex": 0, + "visibility": 20500.0, + "windDirection": 216, + "windGust": 14.64, + "windSpeed": 4.32 + }, + { + "forecastStart": "2023-09-15T19:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.48, + "temperatureApparent": 25.98, + "temperatureDewPoint": 22.39, + "uvIndex": 0, + "visibility": 21319.0, + "windDirection": 198, + "windGust": 14.06, + "windSpeed": 4.73 + }, + { + "forecastStart": "2023-09-15T20:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.8, + "temperatureApparent": 26.34, + "temperatureDewPoint": 22.42, + "uvIndex": 0, + "visibility": 22776.0, + "windDirection": 189, + "windGust": 13.7, + "windSpeed": 5.49 + }, + { + "forecastStart": "2023-09-15T21:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.43, + "temperatureApparent": 27.08, + "temperatureDewPoint": 22.53, + "uvIndex": 0, + "visibility": 24606.0, + "windDirection": 183, + "windGust": 13.77, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-15T22:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.12, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.47, + "temperatureApparent": 28.28, + "temperatureDewPoint": 22.65, + "uvIndex": 1, + "visibility": 26540.0, + "windDirection": 179, + "windGust": 14.38, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-15T23:00:00Z", + "cloudCover": 0.52, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.13, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.85, + "temperatureApparent": 29.91, + "temperatureDewPoint": 22.86, + "uvIndex": 2, + "visibility": 28300.0, + "windDirection": 170, + "windGust": 15.2, + "windSpeed": 5.27 + }, + { + "forecastStart": "2023-09-16T00:00:00Z", + "cloudCover": 0.44, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.04, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.02, + "temperatureApparent": 31.22, + "temperatureDewPoint": 22.86, + "uvIndex": 4, + "visibility": 29608.0, + "windDirection": 155, + "windGust": 15.85, + "windSpeed": 4.76 + }, + { + "forecastStart": "2023-09-16T01:00:00Z", + "cloudCover": 0.24, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.52, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.24, + "temperatureApparent": 32.46, + "temperatureDewPoint": 22.63, + "uvIndex": 6, + "visibility": 30511.0, + "windDirection": 110, + "windGust": 16.27, + "windSpeed": 6.81 + }, + { + "forecastStart": "2023-09-16T02:00:00Z", + "cloudCover": 0.16, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.01, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.25, + "temperatureApparent": 33.46, + "temperatureDewPoint": 22.37, + "uvIndex": 8, + "visibility": 31232.0, + "windDirection": 30, + "windGust": 16.55, + "windSpeed": 6.86 + }, + { + "forecastStart": "2023-09-16T03:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.59, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.45, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.05, + "temperatureApparent": 34.18, + "temperatureDewPoint": 22.04, + "uvIndex": 8, + "visibility": 31751.0, + "windDirection": 17, + "windGust": 16.52, + "windSpeed": 6.8 + }, + { + "forecastStart": "2023-09-16T04:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.57, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.89, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.54, + "temperatureApparent": 34.67, + "temperatureDewPoint": 21.93, + "uvIndex": 8, + "visibility": 32057.0, + "windDirection": 17, + "windGust": 16.08, + "windSpeed": 6.62 + }, + { + "forecastStart": "2023-09-16T05:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.56, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.39, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.77, + "temperatureApparent": 34.92, + "temperatureDewPoint": 21.91, + "uvIndex": 6, + "visibility": 32148.0, + "windDirection": 20, + "windGust": 15.48, + "windSpeed": 6.45 + }, + { + "forecastStart": "2023-09-16T06:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.56, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.11, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.44, + "temperatureApparent": 34.45, + "temperatureDewPoint": 21.72, + "uvIndex": 4, + "visibility": 32012.0, + "windDirection": 26, + "windGust": 15.08, + "windSpeed": 6.43 + }, + { + "forecastStart": "2023-09-16T07:00:00Z", + "cloudCover": 0.07, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.59, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.15, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.69, + "temperatureApparent": 33.61, + "temperatureDewPoint": 21.71, + "uvIndex": 2, + "visibility": 31608.0, + "windDirection": 39, + "windGust": 14.88, + "windSpeed": 6.61 + }, + { + "forecastStart": "2023-09-16T08:00:00Z", + "cloudCover": 0.02, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.61, + "temperatureApparent": 32.49, + "temperatureDewPoint": 21.87, + "uvIndex": 0, + "visibility": 30972.0, + "windDirection": 72, + "windGust": 14.82, + "windSpeed": 6.95 + }, + { + "forecastStart": "2023-09-16T09:00:00Z", + "cloudCover": 0.02, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.75, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.54, + "temperatureApparent": 31.45, + "temperatureDewPoint": 22.15, + "uvIndex": 0, + "visibility": 30211.0, + "windDirection": 116, + "windGust": 15.13, + "windSpeed": 7.45 + }, + { + "forecastStart": "2023-09-16T10:00:00Z", + "cloudCover": 0.13, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.13, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.57, + "temperatureApparent": 30.46, + "temperatureDewPoint": 22.34, + "uvIndex": 0, + "visibility": 29403.0, + "windDirection": 140, + "windGust": 16.09, + "windSpeed": 8.15 + }, + { + "forecastStart": "2023-09-16T11:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.47, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.87, + "temperatureApparent": 29.82, + "temperatureDewPoint": 22.62, + "uvIndex": 0, + "visibility": 28466.0, + "windDirection": 149, + "windGust": 17.37, + "windSpeed": 8.87 + }, + { + "forecastStart": "2023-09-16T12:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.6, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.29, + "temperatureApparent": 29.3, + "temperatureDewPoint": 22.89, + "uvIndex": 0, + "visibility": 27272.0, + "windDirection": 155, + "windGust": 18.29, + "windSpeed": 9.21 + }, + { + "forecastStart": "2023-09-16T13:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.74, + "temperatureApparent": 28.73, + "temperatureDewPoint": 22.99, + "uvIndex": 0, + "visibility": 25405.0, + "windDirection": 159, + "windGust": 18.49, + "windSpeed": 8.96 + }, + { + "forecastStart": "2023-09-16T14:00:00Z", + "cloudCover": 0.55, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.02, + "temperatureApparent": 27.86, + "temperatureDewPoint": 22.82, + "uvIndex": 0, + "visibility": 22840.0, + "windDirection": 162, + "windGust": 18.47, + "windSpeed": 8.45 + }, + { + "forecastStart": "2023-09-16T15:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.55, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.48, + "temperatureApparent": 27.22, + "temperatureDewPoint": 22.73, + "uvIndex": 0, + "visibility": 20049.0, + "windDirection": 162, + "windGust": 18.79, + "windSpeed": 8.1 + }, + { + "forecastStart": "2023-09-16T16:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.1, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.03, + "temperatureApparent": 26.69, + "temperatureDewPoint": 22.65, + "uvIndex": 0, + "visibility": 17483.0, + "windDirection": 162, + "windGust": 19.81, + "windSpeed": 8.15 + }, + { + "forecastStart": "2023-09-16T17:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.68, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.69, + "temperatureApparent": 26.29, + "temperatureDewPoint": 22.6, + "uvIndex": 0, + "visibility": 15558.0, + "windDirection": 161, + "windGust": 20.96, + "windSpeed": 8.3 + }, + { + "forecastStart": "2023-09-16T18:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.5, + "temperatureApparent": 26.01, + "temperatureDewPoint": 22.41, + "uvIndex": 0, + "visibility": 14707.0, + "windDirection": 159, + "windGust": 21.41, + "windSpeed": 8.24 + }, + { + "forecastStart": "2023-09-16T19:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.75, + "temperatureApparent": 26.33, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 15332.0, + "windDirection": 159, + "windGust": 20.42, + "windSpeed": 7.62 + }, + { + "forecastStart": "2023-09-16T20:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.19, + "temperatureApparent": 26.84, + "temperatureDewPoint": 22.59, + "uvIndex": 0, + "visibility": 17205.0, + "windDirection": 158, + "windGust": 18.61, + "windSpeed": 6.66 + }, + { + "forecastStart": "2023-09-16T21:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.92, + "temperatureApparent": 27.67, + "temperatureDewPoint": 22.64, + "uvIndex": 0, + "visibility": 19811.0, + "windDirection": 158, + "windGust": 17.14, + "windSpeed": 5.86 + }, + { + "forecastStart": "2023-09-16T22:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.46, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.0, + "temperatureApparent": 28.85, + "temperatureDewPoint": 22.61, + "uvIndex": 1, + "visibility": 22602.0, + "windDirection": 161, + "windGust": 16.78, + "windSpeed": 5.5 + }, + { + "forecastStart": "2023-09-16T23:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.51, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.47, + "temperatureApparent": 30.6, + "temperatureDewPoint": 22.86, + "uvIndex": 2, + "visibility": 24958.0, + "windDirection": 165, + "windGust": 17.21, + "windSpeed": 5.56 + }, + { + "forecastStart": "2023-09-17T00:00:00Z", + "cloudCover": 0.33, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.71, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.49, + "temperatureApparent": 31.7, + "temperatureDewPoint": 22.77, + "uvIndex": 4, + "visibility": 26230.0, + "windDirection": 174, + "windGust": 17.96, + "windSpeed": 6.04 + }, + { + "forecastStart": "2023-09-17T01:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.35, + "temperatureApparent": 32.64, + "temperatureDewPoint": 22.73, + "uvIndex": 6, + "visibility": 26296.0, + "windDirection": 192, + "windGust": 19.15, + "windSpeed": 7.23 + }, + { + "forecastStart": "2023-09-17T02:00:00Z", + "cloudCover": 0.29, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.38, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.14, + "temperatureApparent": 33.56, + "temperatureDewPoint": 22.78, + "uvIndex": 7, + "visibility": 25582.0, + "windDirection": 225, + "windGust": 20.89, + "windSpeed": 8.9 + }, + { + "forecastStart": "2023-09-17T03:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.09, + "precipitationType": "rain", + "pressure": 1009.75, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.66, + "temperatureApparent": 34.13, + "temperatureDewPoint": 22.76, + "uvIndex": 8, + "visibility": 24257.0, + "windDirection": 264, + "windGust": 22.67, + "windSpeed": 10.27 + }, + { + "forecastStart": "2023-09-17T04:00:00Z", + "cloudCover": 0.37, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.62, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1009.18, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.54, + "temperatureApparent": 33.88, + "temperatureDewPoint": 22.54, + "uvIndex": 7, + "visibility": 22565.0, + "windDirection": 293, + "windGust": 23.93, + "windSpeed": 10.82 + }, + { + "forecastStart": "2023-09-17T05:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.6, + "precipitationIntensity": 0.6, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1008.71, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.15, + "temperatureApparent": 33.36, + "temperatureDewPoint": 22.38, + "uvIndex": 5, + "visibility": 20796.0, + "windDirection": 308, + "windGust": 24.39, + "windSpeed": 10.72 + }, + { + "forecastStart": "2023-09-17T06:00:00Z", + "cloudCover": 0.5, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1008.46, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.62, + "temperatureApparent": 32.67, + "temperatureDewPoint": 22.19, + "uvIndex": 3, + "visibility": 19195.0, + "windDirection": 312, + "windGust": 23.9, + "windSpeed": 10.28 + }, + { + "forecastStart": "2023-09-17T07:00:00Z", + "cloudCover": 0.47, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1008.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.91, + "temperatureApparent": 31.84, + "temperatureDewPoint": 22.12, + "uvIndex": 1, + "visibility": 17604.0, + "windDirection": 312, + "windGust": 22.3, + "windSpeed": 9.59 + }, + { + "forecastStart": "2023-09-17T08:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.6, + "precipitationIntensity": 0.6, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1008.82, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.91, + "temperatureApparent": 30.64, + "temperatureDewPoint": 21.93, + "uvIndex": 0, + "visibility": 15869.0, + "windDirection": 305, + "windGust": 19.73, + "windSpeed": 8.58 + }, + { + "forecastStart": "2023-09-17T09:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.74, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1009.21, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.99, + "temperatureApparent": 29.64, + "temperatureDewPoint": 21.96, + "uvIndex": 0, + "visibility": 14244.0, + "windDirection": 291, + "windGust": 16.49, + "windSpeed": 7.34 + }, + { + "forecastStart": "2023-09-17T10:00:00Z", + "cloudCover": 0.33, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1009.65, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.1, + "temperatureApparent": 28.63, + "temperatureDewPoint": 21.88, + "uvIndex": 0, + "visibility": 12808.0, + "windDirection": 257, + "windGust": 12.71, + "windSpeed": 5.91 + }, + { + "forecastStart": "2023-09-17T11:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1010.04, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.29, + "temperatureApparent": 27.76, + "temperatureDewPoint": 21.92, + "uvIndex": 0, + "visibility": 11601.0, + "windDirection": 212, + "windGust": 9.16, + "windSpeed": 4.54 + }, + { + "forecastStart": "2023-09-17T12:00:00Z", + "cloudCover": 0.36, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.28, + "precipitationType": "rain", + "pressure": 1010.24, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.65, + "temperatureApparent": 27.06, + "temperatureDewPoint": 21.92, + "uvIndex": 0, + "visibility": 10807.0, + "windDirection": 192, + "windGust": 7.09, + "windSpeed": 3.62 + }, + { + "forecastStart": "2023-09-17T13:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.3, + "precipitationType": "rain", + "pressure": 1010.15, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.15, + "temperatureApparent": 26.54, + "temperatureDewPoint": 21.96, + "uvIndex": 0, + "visibility": 10514.0, + "windDirection": 185, + "windGust": 7.2, + "windSpeed": 3.27 + }, + { + "forecastStart": "2023-09-17T14:00:00Z", + "cloudCover": 0.44, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.3, + "precipitationType": "rain", + "pressure": 1009.87, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.6, + "temperatureApparent": 25.87, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 10700.0, + "windDirection": 182, + "windGust": 8.37, + "windSpeed": 3.22 + }, + { + "forecastStart": "2023-09-17T15:00:00Z", + "cloudCover": 0.49, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.31, + "precipitationType": "rain", + "pressure": 1009.56, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.21, + "temperatureApparent": 25.46, + "temperatureDewPoint": 21.84, + "uvIndex": 0, + "visibility": 11364.0, + "windDirection": 180, + "windGust": 9.21, + "windSpeed": 3.3 + }, + { + "forecastStart": "2023-09-17T16:00:00Z", + "cloudCover": 0.53, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.33, + "precipitationType": "rain", + "pressure": 1009.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.87, + "temperatureApparent": 25.08, + "temperatureDewPoint": 21.78, + "uvIndex": 0, + "visibility": 12623.0, + "windDirection": 182, + "windGust": 9.0, + "windSpeed": 3.46 + }, + { + "forecastStart": "2023-09-17T17:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.35, + "precipitationType": "clear", + "pressure": 1009.09, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.62, + "temperatureApparent": 24.79, + "temperatureDewPoint": 21.74, + "uvIndex": 0, + "visibility": 14042.0, + "windDirection": 186, + "windGust": 8.37, + "windSpeed": 3.72 + }, + { + "forecastStart": "2023-09-17T18:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.37, + "precipitationType": "clear", + "pressure": 1009.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.47, + "temperatureApparent": 24.57, + "temperatureDewPoint": 21.59, + "uvIndex": 0, + "visibility": 14809.0, + "windDirection": 201, + "windGust": 7.99, + "windSpeed": 4.07 + }, + { + "forecastStart": "2023-09-17T19:00:00Z", + "cloudCover": 0.62, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.39, + "precipitationType": "clear", + "pressure": 1009.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.68, + "temperatureApparent": 24.85, + "temperatureDewPoint": 21.73, + "uvIndex": 0, + "visibility": 14586.0, + "windDirection": 258, + "windGust": 8.18, + "windSpeed": 4.55 + }, + { + "forecastStart": "2023-09-17T20:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.39, + "precipitationType": "clear", + "pressure": 1009.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.01, + "temperatureApparent": 25.2, + "temperatureDewPoint": 21.71, + "uvIndex": 0, + "visibility": 13831.0, + "windDirection": 305, + "windGust": 8.77, + "windSpeed": 5.17 + }, + { + "forecastStart": "2023-09-17T21:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.38, + "precipitationType": "clear", + "pressure": 1009.47, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.51, + "temperatureApparent": 25.77, + "temperatureDewPoint": 21.77, + "uvIndex": 0, + "visibility": 12945.0, + "windDirection": 318, + "windGust": 9.69, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-17T22:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.3, + "precipitationType": "clear", + "pressure": 1009.77, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.21, + "temperatureApparent": 26.53, + "temperatureDewPoint": 21.79, + "uvIndex": 1, + "visibility": 12093.0, + "windDirection": 324, + "windGust": 10.88, + "windSpeed": 6.26 + }, + { + "forecastStart": "2023-09-17T23:00:00Z", + "cloudCover": 0.8, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.09, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.08, + "temperatureApparent": 27.55, + "temperatureDewPoint": 21.95, + "uvIndex": 2, + "visibility": 11231.0, + "windDirection": 329, + "windGust": 12.21, + "windSpeed": 6.68 + }, + { + "forecastStart": "2023-09-18T00:00:00Z", + "cloudCover": 0.87, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.8, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.33, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.71, + "temperatureApparent": 28.22, + "temperatureDewPoint": 21.92, + "uvIndex": 3, + "visibility": 10426.0, + "windDirection": 332, + "windGust": 13.52, + "windSpeed": 7.12 + }, + { + "forecastStart": "2023-09-18T01:00:00Z", + "cloudCover": 0.67, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1007.43, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.19, + "temperatureApparent": 29.75, + "temperatureDewPoint": 21.7, + "uvIndex": 5, + "visibility": 24135.0, + "windDirection": 330, + "windGust": 11.36, + "windSpeed": 11.36 + }, + { + "forecastStart": "2023-09-18T02:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.09, + "precipitationType": "rain", + "pressure": 1007.05, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.51, + "temperatureApparent": 30.07, + "temperatureDewPoint": 21.64, + "uvIndex": 6, + "visibility": 24135.0, + "windDirection": 332, + "windGust": 12.06, + "windSpeed": 12.06 + }, + { + "forecastStart": "2023-09-18T03:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1006.67, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.75, + "temperatureApparent": 30.31, + "temperatureDewPoint": 21.59, + "uvIndex": 6, + "visibility": 24135.0, + "windDirection": 333, + "windGust": 12.81, + "windSpeed": 12.81 + }, + { + "forecastStart": "2023-09-18T04:00:00Z", + "cloudCover": 0.67, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1006.28, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.99, + "temperatureApparent": 30.55, + "temperatureDewPoint": 21.53, + "uvIndex": 5, + "visibility": 24135.0, + "windDirection": 335, + "windGust": 13.68, + "windSpeed": 13.68 + }, + { + "forecastStart": "2023-09-18T05:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1005.89, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.15, + "temperatureApparent": 30.66, + "temperatureDewPoint": 21.4, + "uvIndex": 4, + "visibility": 24135.0, + "windDirection": 336, + "windGust": 14.61, + "windSpeed": 14.61 + }, + { + "forecastStart": "2023-09-18T06:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.27, + "precipitationType": "clear", + "pressure": 1005.67, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.92, + "temperatureApparent": 30.31, + "temperatureDewPoint": 21.18, + "uvIndex": 3, + "visibility": 24135.0, + "windDirection": 338, + "windGust": 15.25, + "windSpeed": 15.25 + }, + { + "forecastStart": "2023-09-18T07:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.28, + "precipitationType": "clear", + "pressure": 1005.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.4, + "temperatureApparent": 29.78, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 24135.0, + "windDirection": 339, + "windGust": 15.45, + "windSpeed": 15.45 + }, + { + "forecastStart": "2023-09-18T08:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1005.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.73, + "temperatureApparent": 29.13, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 341, + "windGust": 15.38, + "windSpeed": 15.38 + }, + { + "forecastStart": "2023-09-18T09:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1006.22, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.12, + "temperatureApparent": 28.55, + "temperatureDewPoint": 21.64, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 341, + "windGust": 15.27, + "windSpeed": 15.27 + }, + { + "forecastStart": "2023-09-18T10:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1006.44, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.56, + "temperatureApparent": 27.93, + "temperatureDewPoint": 21.61, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 339, + "windGust": 15.09, + "windSpeed": 15.09 + }, + { + "forecastStart": "2023-09-18T11:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.81, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1006.66, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.19, + "temperatureApparent": 27.58, + "temperatureDewPoint": 21.74, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 336, + "windGust": 14.88, + "windSpeed": 14.88 + }, + { + "forecastStart": "2023-09-18T12:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1006.79, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.83, + "temperatureApparent": 27.2, + "temperatureDewPoint": 21.78, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 333, + "windGust": 14.91, + "windSpeed": 14.91 + }, + { + "forecastStart": "2023-09-18T13:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.36, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.63, + "temperatureApparent": 25.69, + "temperatureDewPoint": 21.23, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 83, + "windGust": 4.58, + "windSpeed": 3.16 + }, + { + "forecastStart": "2023-09-18T14:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.96, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.13, + "temperatureApparent": 25.13, + "temperatureDewPoint": 21.18, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 144, + "windGust": 4.74, + "windSpeed": 4.52 + }, + { + "forecastStart": "2023-09-18T15:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.6, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.6, + "temperatureApparent": 24.48, + "temperatureDewPoint": 20.95, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 152, + "windGust": 5.63, + "windSpeed": 5.63 + }, + { + "forecastStart": "2023-09-18T16:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.37, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.27, + "temperatureApparent": 24.04, + "temperatureDewPoint": 20.69, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 156, + "windGust": 6.02, + "windSpeed": 6.02 + }, + { + "forecastStart": "2023-09-18T17:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.2, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.02, + "temperatureApparent": 23.69, + "temperatureDewPoint": 20.45, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 162, + "windGust": 6.15, + "windSpeed": 6.15 + }, + { + "forecastStart": "2023-09-18T18:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.08, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.88, + "temperatureApparent": 23.45, + "temperatureDewPoint": 20.16, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 167, + "windGust": 6.48, + "windSpeed": 6.48 + }, + { + "forecastStart": "2023-09-18T19:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.04, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.76, + "temperatureApparent": 23.19, + "temperatureDewPoint": 19.76, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 165, + "windGust": 7.51, + "windSpeed": 7.51 + }, + { + "forecastStart": "2023-09-18T20:00:00Z", + "cloudCover": 0.99, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.96, + "temperatureApparent": 23.35, + "temperatureDewPoint": 19.58, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 162, + "windGust": 8.73, + "windSpeed": 8.73 + }, + { + "forecastStart": "2023-09-18T21:00:00Z", + "cloudCover": 0.98, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.06, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.53, + "temperatureApparent": 23.93, + "temperatureDewPoint": 19.54, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 164, + "windGust": 9.21, + "windSpeed": 9.11 + }, + { + "forecastStart": "2023-09-18T22:00:00Z", + "cloudCover": 0.96, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.8, + "temperatureApparent": 25.34, + "temperatureDewPoint": 19.73, + "uvIndex": 1, + "visibility": 24204.0, + "windDirection": 171, + "windGust": 9.03, + "windSpeed": 7.91 + } + ] + } +} diff --git a/tests/components/weatherkit/snapshots/test_weather.ambr b/tests/components/weatherkit/snapshots/test_weather.ambr new file mode 100644 index 00000000000..63321b5a813 --- /dev/null +++ b/tests/components/weatherkit/snapshots/test_weather.ambr @@ -0,0 +1,4087 @@ +# serializer version: 1 +# name: test_daily_forecast + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }) +# --- +# name: test_hourly_forecast + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }) +# --- diff --git a/tests/components/weatherkit/test_config_flow.py b/tests/components/weatherkit/test_config_flow.py new file mode 100644 index 00000000000..4faaac15db6 --- /dev/null +++ b/tests/components/weatherkit/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the Apple WeatherKit config flow.""" +from unittest.mock import AsyncMock, patch + +from apple_weatherkit import DataSetType +from apple_weatherkit.client import ( + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientCommunicationError, + WeatherKitApiClientError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.weatherkit.config_flow import ( + WeatherKitUnsupportedLocationError, +) +from homeassistant.components.weatherkit.const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, +) +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import EXAMPLE_CONFIG_DATA + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +EXAMPLE_USER_INPUT = { + CONF_LOCATION: { + CONF_LATITUDE: 35.4690101707532, + CONF_LONGITUDE: 135.74817234593166, + }, + CONF_KEY_ID: "QABCDEFG123", + CONF_SERVICE_ID: "io.home-assistant.testing", + CONF_TEAM_ID: "ABCD123456", + CONF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nwhateverkey\n-----END PRIVATE KEY-----", +} + + +async def _test_exception_generates_error( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form and create an entry.""" + 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.weatherkit.config_flow.WeatherKitFlowHandler._test_config", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + location = EXAMPLE_USER_INPUT[CONF_LOCATION] + assert result["title"] == f"{location[CONF_LATITUDE]}, {location[CONF_LONGITUDE]}" + + assert result["data"] == EXAMPLE_CONFIG_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (WeatherKitApiClientAuthenticationError, "invalid_auth"), + (WeatherKitApiClientCommunicationError, "cannot_connect"), + (WeatherKitUnsupportedLocationError, "unsupported_location"), + (WeatherKitApiClientError, "unknown"), + ], +) +async def test_error_handling( + hass: HomeAssistant, exception: Exception, expected_error: str +) -> None: + """Test that we handle various exceptions and generate appropriate errors.""" + await _test_exception_generates_error(hass, exception, expected_error) + + +async def test_form_unsupported_location(hass: HomeAssistant) -> None: + """Test we handle when WeatherKit does not support the location.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_location"} + + # Test that we can recover from this error by changing the location + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[DataSetType.CURRENT_WEATHER], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py new file mode 100644 index 00000000000..f619ace237a --- /dev/null +++ b/tests/components/weatherkit/test_coordinator.py @@ -0,0 +1,32 @@ +"""Test WeatherKit data coordinator.""" +from datetime import timedelta +from unittest.mock import patch + +from apple_weatherkit.client import WeatherKitApiClientError + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from . import init_integration + +from tests.common import async_fire_time_changed + + +async def test_failed_updates(hass: HomeAssistant) -> None: + """Test that we properly handle failed updates.""" + await init_integration(hass) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ): + async_fire_time_changed( + hass, + utcnow() + timedelta(minutes=15), + ) + await hass.async_block_till_done() + + state = hass.states.get("weather.home") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/weatherkit/test_setup.py b/tests/components/weatherkit/test_setup.py new file mode 100644 index 00000000000..5f94d4100d5 --- /dev/null +++ b/tests/components/weatherkit/test_setup.py @@ -0,0 +1,63 @@ +"""Test the WeatherKit setup process.""" +from unittest.mock import patch + +from apple_weatherkit.client import ( + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.weatherkit import async_setup_entry +from homeassistant.components.weatherkit.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import EXAMPLE_CONFIG_DATA + +from tests.common import MockConfigEntry + + +async def test_auth_error_handling(hass: HomeAssistant) -> None: + """Test that we handle authentication errors at setup properly.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientAuthenticationError, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=WeatherKitApiClientAuthenticationError, + ): + entry.add_to_hass(hass) + setup_result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert setup_result is False + + +async def test_client_error_handling(hass: HomeAssistant) -> None: + """Test that we handle API client errors at setup properly.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + with pytest.raises(ConfigEntryNotReady), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=WeatherKitApiClientError, + ): + entry.add_to_hass(hass) + config_entries.current_entry.set(entry) + await async_setup_entry(hass, entry) + await hass.async_block_till_done() diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py new file mode 100644 index 00000000000..fabd3aab572 --- /dev/null +++ b/tests/components/weatherkit/test_weather.py @@ -0,0 +1,115 @@ +"""Weather entity tests for the WeatherKit integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.weather import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + 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, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, +) +from homeassistant.components.weather.const import WeatherEntityFeature +from homeassistant.components.weatherkit.const import ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_current_weather(hass: HomeAssistant) -> None: + """Test states of the current weather.""" + await init_integration(hass) + + state = hass.states.get("weather.home") + assert state + assert state.state == "partlycloudy" + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 91 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1009.8 + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 22.9 + assert state.attributes[ATTR_WEATHER_VISIBILITY] == 20.97 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 259 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 5.23 + assert state.attributes[ATTR_WEATHER_APPARENT_TEMPERATURE] == 24.9 + assert state.attributes[ATTR_WEATHER_DEW_POINT] == 21.3 + assert state.attributes[ATTR_WEATHER_CLOUD_COVERAGE] == 62 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 10.53 + assert state.attributes[ATTR_WEATHER_UV_INDEX] == 1 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + + +async def test_current_weather_nighttime(hass: HomeAssistant) -> None: + """Test that the condition is clear-night when it's sunny and night time.""" + await init_integration(hass, is_night_time=True) + + state = hass.states.get("weather.home") + assert state + assert state.state == "clear-night" + + +async def test_daily_forecast_missing(hass: HomeAssistant) -> None: + """Test that daily forecast is not supported when WeatherKit doesn't support it.""" + await init_integration(hass, has_daily_forecast=False) + + state = hass.states.get("weather.home") + assert state + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] & WeatherEntityFeature.FORECAST_DAILY + ) == 0 + + +async def test_hourly_forecast_missing(hass: HomeAssistant) -> None: + """Test that hourly forecast is not supported when WeatherKit doesn't support it.""" + await init_integration(hass, has_hourly_forecast=False) + + state = hass.states.get("weather.home") + assert state + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] & WeatherEntityFeature.FORECAST_HOURLY + ) == 0 + + +async def test_hourly_forecast( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test states of the hourly forecast.""" + await init_integration(hass) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.home", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + +async def test_daily_forecast(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test states of the daily forecast.""" + await init_integration(hass) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.home", + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot From fdb9ac20c3a25e739c83b673f5f5d7b6e5644ec7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 12:08:48 -0500 Subject: [PATCH 391/984] Migrate mobile_app to use json helper (#100136) --- homeassistant/components/mobile_app/helpers.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index e8460b721a2..e9bb3af51f2 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping from http import HTTPStatus -import json import logging from typing import Any @@ -14,7 +13,7 @@ from nacl.secret import SecretBox from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import json_bytes from homeassistant.util.json import JsonValueType, json_loads from .const import ( @@ -182,7 +181,7 @@ def webhook_response( headers: Mapping[str, str] | None = None, ) -> Response: """Return a encrypted response if registration supports it.""" - data = json.dumps(data, cls=JSONEncoder) + json_data = json_bytes(data) if registration[ATTR_SUPPORTS_ENCRYPTION]: keylen, encrypt = setup_encrypt( @@ -190,17 +189,17 @@ def webhook_response( ) if ATTR_NO_LEGACY_ENCRYPTION in registration: - key = registration[CONF_SECRET] + key: bytes = registration[CONF_SECRET] else: key = registration[CONF_SECRET].encode("utf-8") key = key[:keylen] key = key.ljust(keylen, b"\0") - enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8") - data = json.dumps({"encrypted": True, "encrypted_data": enc_data}) + enc_data = encrypt(json_data, key).decode("utf-8") + json_data = json_bytes({"encrypted": True, "encrypted_data": enc_data}) return Response( - text=data, status=status, content_type=CONTENT_TYPE_JSON, headers=headers + body=json_data, status=status, content_type=CONTENT_TYPE_JSON, headers=headers ) From d5fc92eb9027df063b8c759a9e628563f66746d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 13:34:35 -0500 Subject: [PATCH 392/984] Bump zeroconf to 0.107.0 (#100134) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.105.0...0.107.0 --- 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 0457f7fd1c3..8a91b14a846 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.105.0"] + "requirements": ["zeroconf==0.107.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4d2d45de477..bd5fdcd9dd5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.105.0 +zeroconf==0.107.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 4f22107c76f..1ecf2917120 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.105.0 +zeroconf==0.107.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9df6f6b1a11..0180ee773ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2045,7 +2045,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.105.0 +zeroconf==0.107.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From ad5e9e9f5b1b9e54e3c6c9c967c9b504bc6cce6d Mon Sep 17 00:00:00 2001 From: Niels Perfors Date: Mon, 11 Sep 2023 20:43:59 +0200 Subject: [PATCH 393/984] Remove code owner Verisure (#100145) --- CODEOWNERS | 4 ++-- homeassistant/components/verisure/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 29c744ce42e..9771a9e25e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1363,8 +1363,8 @@ build.json @home-assistant/supervisor /homeassistant/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 /tests/components/venstar/ @garbled1 -/homeassistant/components/verisure/ @frenck @niro1987 -/tests/components/verisure/ @frenck @niro1987 +/homeassistant/components/verisure/ @frenck +/tests/components/verisure/ @frenck /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 7c9e7057b0c..70c0505929d 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck", "@niro1987"], + "codeowners": ["@frenck"], "config_flow": true, "dhcp": [ { From 5c206de9065259ca1eeaa745a42c8ff9c25769f1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 21:06:20 +0200 Subject: [PATCH 394/984] Decouple Withings webhook tests from YAML (#100143) --- homeassistant/components/withings/common.py | 6 +- tests/components/withings/__init__.py | 85 +++-------- tests/components/withings/common.py | 9 +- tests/components/withings/conftest.py | 75 +++++---- .../withings/fixtures/get_device.json | 15 ++ .../{person0_get_meas.json => get_meas.json} | 0 ...{person0_get_sleep.json => get_sleep.json} | 0 .../withings/fixtures/notify_list.json | 22 +++ .../withings/fixtures/person0_get_device.json | 18 --- .../fixtures/person0_notify_list.json | 3 - .../components/withings/test_binary_sensor.py | 61 ++++---- tests/components/withings/test_common.py | 144 +----------------- tests/components/withings/test_config_flow.py | 2 + tests/components/withings/test_init.py | 67 +++++++- tests/components/withings/test_sensor.py | 93 +++++------ 15 files changed, 242 insertions(+), 358 deletions(-) create mode 100644 tests/components/withings/fixtures/get_device.json rename tests/components/withings/fixtures/{person0_get_meas.json => get_meas.json} (100%) rename tests/components/withings/fixtures/{person0_get_sleep.json => get_sleep.json} (100%) create mode 100644 tests/components/withings/fixtures/notify_list.json delete mode 100644 tests/components/withings/fixtures/person0_get_device.json delete mode 100644 tests/components/withings/fixtures/person0_notify_list.json diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 76124cfff91..516c306cc0f 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -53,6 +53,8 @@ NOT_AUTHENTICATED_ERROR = re.compile( re.IGNORECASE, ) DATA_UPDATED_SIGNAL = "withings_entity_state_updated" +SUBSCRIBE_DELAY = datetime.timedelta(seconds=5) +UNSUBSCRIBE_DELAY = datetime.timedelta(seconds=1) class UpdateType(StrEnum): @@ -229,8 +231,8 @@ class DataManager: self._user_id = user_id self._profile = profile self._webhook_config = webhook_config - self._notify_subscribe_delay = datetime.timedelta(seconds=5) - self._notify_unsubscribe_delay = datetime.timedelta(seconds=1) + self._notify_subscribe_delay = SUBSCRIBE_DELAY + self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY self._is_available = True self._cancel_interval_update_interval: CALLBACK_TYPE | None = None diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index b87188f3022..94c7511054f 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1,27 +1,21 @@ """Tests for the withings component.""" -from collections.abc import Iterable +from dataclasses import dataclass from typing import Any from urllib.parse import urlparse -import arrow -from withings_api import DateType -from withings_api.common import ( - GetSleepSummaryField, - MeasureGetMeasGroupCategory, - MeasureGetMeasResponse, - MeasureType, - NotifyAppli, - NotifyListResponse, - SleepGetSummaryResponse, - UserGetDeviceResponse, -) - from homeassistant.components.webhook import async_generate_url +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from .common import WebhookResponse +from tests.common import MockConfigEntry -from tests.common import load_json_object_fixture + +@dataclass +class WebhookResponse: + """Response data from a webhook.""" + + message: str + message_code: int async def call_webhook( @@ -44,56 +38,13 @@ async def call_webhook( return WebhookResponse(message=data["message"], message_code=data["code"]) -class MockWithings: - """Mock object for Withings.""" +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) - def __init__( - self, - device_fixture: str = "person0_get_device.json", - measurement_fixture: str = "person0_get_meas.json", - sleep_fixture: str = "person0_get_sleep.json", - notify_list_fixture: str = "person0_notify_list.json", - ): - """Initialize mock.""" - self.device_fixture = device_fixture - self.measurement_fixture = measurement_fixture - self.sleep_fixture = sleep_fixture - self.notify_list_fixture = notify_list_fixture + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) - def user_get_device(self) -> UserGetDeviceResponse: - """Get devices.""" - fixture = load_json_object_fixture(f"withings/{self.device_fixture}") - return UserGetDeviceResponse(**fixture) - - def measure_get_meas( - self, - meastype: MeasureType | None = None, - category: MeasureGetMeasGroupCategory | None = None, - startdate: DateType | None = None, - enddate: DateType | None = None, - offset: int | None = None, - lastupdate: DateType | None = None, - ) -> MeasureGetMeasResponse: - """Get measurements.""" - fixture = load_json_object_fixture(f"withings/{self.measurement_fixture}") - return MeasureGetMeasResponse(**fixture) - - def sleep_get_summary( - self, - data_fields: Iterable[GetSleepSummaryField], - startdateymd: DateType | None = arrow.utcnow(), - enddateymd: DateType | None = arrow.utcnow(), - offset: int | None = None, - lastupdate: DateType | None = arrow.utcnow(), - ) -> SleepGetSummaryResponse: - """Get sleep.""" - fixture = load_json_object_fixture(f"withings/{self.sleep_fixture}") - return SleepGetSummaryResponse(**fixture) - - def notify_list( - self, - appli: NotifyAppli | None = None, - ) -> NotifyListResponse: - """Get sleep.""" - fixture = load_json_object_fixture(f"withings/{self.notify_list_fixture}") - return NotifyListResponse(**fixture) + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index e5c246dc95e..6bb1b30917c 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -44,6 +44,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +from tests.components.withings import WebhookResponse from tests.test_util.aiohttp import AiohttpClientMocker @@ -91,14 +92,6 @@ def new_profile_config( ) -@dataclass -class WebhookResponse: - """Response data from a webhook.""" - - message: str - message_code: int - - class ComponentFactory: """Manages the setup and unloading of the withing component and profiles.""" diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 8a85b523769..fdd076e2f43 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -1,28 +1,30 @@ """Fixtures for tests.""" -from collections.abc import Awaitable, Callable, Coroutine +from datetime import timedelta import time -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from withings_api import ( + MeasureGetMeasResponse, + NotifyListResponse, + SleepGetSummaryResponse, + UserGetDeviceResponse, +) from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.components.withings.common import ConfigEntryWithingsApi from homeassistant.components.withings.const import DOMAIN -from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import MockWithings from .common import ComponentFactory -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMocker -ComponentSetup = Callable[[], Awaitable[MockWithings]] - CLIENT_ID = "1234" CLIENT_SECRET = "5678" SCOPES = [ @@ -100,33 +102,40 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture(name="setup_integration") -async def mock_setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> Callable[[], Coroutine[Any, Any, MockWithings]]: - """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) +@pytest.fixture(name="withings") +def mock_withings(): + """Mock withings.""" - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - DOMAIN, + mock = AsyncMock(spec=ConfigEntryWithingsApi) + mock.user_get_device.return_value = UserGetDeviceResponse( + **load_json_object_fixture("withings/get_device.json") ) - await async_process_ha_core_config( - hass, - {"internal_url": "http://example.local:8123"}, + mock.measure_get_meas.return_value = MeasureGetMeasResponse( + **load_json_object_fixture("withings/get_meas.json") + ) + mock.sleep_get_summary.return_value = SleepGetSummaryResponse( + **load_json_object_fixture("withings/get_sleep.json") + ) + mock.notify_list.return_value = NotifyListResponse( + **load_json_object_fixture("withings/notify_list.json") ) - async def func() -> MockWithings: - mock = MockWithings() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - return_value=mock, - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - return mock + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + yield mock - return func + +@pytest.fixture(name="disable_webhook_delay") +def disable_webhook_delay(): + """Disable webhook delay.""" + + mock = AsyncMock() + with patch( + "homeassistant.components.withings.common.SUBSCRIBE_DELAY", timedelta(seconds=0) + ), patch( + "homeassistant.components.withings.common.UNSUBSCRIBE_DELAY", + timedelta(seconds=0), + ): + yield mock diff --git a/tests/components/withings/fixtures/get_device.json b/tests/components/withings/fixtures/get_device.json new file mode 100644 index 00000000000..64bac3d4a19 --- /dev/null +++ b/tests/components/withings/fixtures/get_device.json @@ -0,0 +1,15 @@ +{ + "devices": [ + { + "type": "Scale", + "battery": "high", + "model": "Body+", + "model_id": 5, + "timezone": "Europe/Amsterdam", + "first_session_date": null, + "last_session_date": 1693867179, + "deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d", + "hash_deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d" + } + ] +} diff --git a/tests/components/withings/fixtures/person0_get_meas.json b/tests/components/withings/fixtures/get_meas.json similarity index 100% rename from tests/components/withings/fixtures/person0_get_meas.json rename to tests/components/withings/fixtures/get_meas.json diff --git a/tests/components/withings/fixtures/person0_get_sleep.json b/tests/components/withings/fixtures/get_sleep.json similarity index 100% rename from tests/components/withings/fixtures/person0_get_sleep.json rename to tests/components/withings/fixtures/get_sleep.json diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json new file mode 100644 index 00000000000..bc696db583a --- /dev/null +++ b/tests/components/withings/fixtures/notify_list.json @@ -0,0 +1,22 @@ +{ + "profiles": [ + { + "appli": 50, + "callbackurl": "https://not.my.callback/url", + "expires": 2147483647, + "comment": null + }, + { + "appli": 50, + "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + }, + { + "appli": 51, + "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + } + ] +} diff --git a/tests/components/withings/fixtures/person0_get_device.json b/tests/components/withings/fixtures/person0_get_device.json deleted file mode 100644 index 8b5e2686686..00000000000 --- a/tests/components/withings/fixtures/person0_get_device.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "status": 0, - "body": { - "devices": [ - { - "type": "Scale", - "battery": "high", - "model": "Body+", - "model_id": 5, - "timezone": "Europe/Amsterdam", - "first_session_date": null, - "last_session_date": 1693867179, - "deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d", - "hash_deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d" - } - ] - } -} diff --git a/tests/components/withings/fixtures/person0_notify_list.json b/tests/components/withings/fixtures/person0_notify_list.json deleted file mode 100644 index c905c95e4cb..00000000000 --- a/tests/components/withings/fixtures/person0_notify_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "profiles": [] -} diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index e9eebbe3567..6629ba5730b 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,51 +1,50 @@ """Tests for the Withings component.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import MockWithings, call_webhook -from .conftest import USER_ID, WEBHOOK_ID, ComponentSetup +from . import call_webhook, setup_integration +from .conftest import USER_ID, WEBHOOK_ID +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator async def test_binary_sensor( hass: HomeAssistant, - setup_integration: ComponentSetup, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - await setup_integration() - mock = MockWithings() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - return_value=mock, - ): - client = await hass_client_no_auth() + await setup_integration(hass, config_entry) - entity_id = "binary_sensor.henk_in_bed" + client = await hass_client_no_auth() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + entity_id = "binary_sensor.henk_in_bed" - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, - client, - ) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, - client, - ) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, + ) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON + + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, + client, + ) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py index 91915a47920..80f5700d64c 100644 --- a/tests/components/withings/test_common.py +++ b/tests/components/withings/test_common.py @@ -1,5 +1,4 @@ """Tests for the Withings component.""" -import datetime from http import HTTPStatus import re from typing import Any @@ -9,20 +8,15 @@ from urllib.parse import urlparse from aiohttp.test_utils import TestClient import pytest import requests_mock -from withings_api.common import NotifyAppli, NotifyListProfile, NotifyListResponse +from withings_api.common import NotifyAppli -from homeassistant.components.withings.common import ( - ConfigEntryWithingsApi, - DataManager, - WebhookConfig, -) +from homeassistant.components.withings.common import ConfigEntryWithingsApi from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -101,137 +95,3 @@ async def test_webhook_post( resp.close() assert data["code"] == expected_code - - -async def test_webhook_head( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - current_request_with_host: None, -) -> None: - """Test head method on webhook view.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - resp = await client.head(urlparse(data_manager.webhook_config.url).path) - assert resp.status == HTTPStatus.OK - - -async def test_webhook_put( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - current_request_with_host: None, -) -> None: - """Test webhook callback.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - resp = await client.put(urlparse(data_manager.webhook_config.url).path) - - # Wait for remaining tasks to complete. - await hass.async_block_till_done() - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data - assert data["code"] == 2 - - -async def test_data_manager_webhook_subscription( - hass: HomeAssistant, - component_factory: ComponentFactory, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test data manager webhook subscriptions.""" - person0 = new_profile_config("person0", 0) - await component_factory.configure_component(profile_configs=(person0,)) - - api: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) - data_manager = DataManager( - hass, - "person0", - api, - 0, - WebhookConfig(id="1234", url="http://localhost/api/webhook/1234", enabled=True), - ) - - data_manager._notify_subscribe_delay = datetime.timedelta(seconds=0) - data_manager._notify_unsubscribe_delay = datetime.timedelta(seconds=0) - - api.notify_list.return_value = NotifyListResponse( - profiles=( - NotifyListProfile( - appli=NotifyAppli.BED_IN, - callbackurl="https://not.my.callback/url", - expires=None, - comment=None, - ), - NotifyListProfile( - appli=NotifyAppli.BED_IN, - callbackurl=data_manager.webhook_config.url, - expires=None, - comment=None, - ), - NotifyListProfile( - appli=NotifyAppli.BED_OUT, - callbackurl=data_manager.webhook_config.url, - expires=None, - comment=None, - ), - ) - ) - - aioclient_mock.clear_requests() - aioclient_mock.request( - "HEAD", - data_manager.webhook_config.url, - status=HTTPStatus.OK, - ) - - # Test subscribing - await data_manager.async_subscribe_webhook() - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.WEIGHT - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.CIRCULATORY - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.ACTIVITY - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.SLEEP - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.USER - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_IN - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_OUT - ) - - # Test unsubscribing. - await data_manager.async_unsubscribe_webhook() - api.notify_revoke.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_IN - ) - api.notify_revoke.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_OUT - ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 51403e67225..360766e0286 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -86,6 +86,7 @@ async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, current_request_with_host: None, + disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" @@ -154,6 +155,7 @@ async def test_config_reauth_profile( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, + disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth an existing profile re-creates the config entry.""" diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 9ccc53d0b88..acd21886e78 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,11 +1,14 @@ """Tests for the Withings component.""" -from unittest.mock import MagicMock, patch +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import urlparse import pytest import voluptuous as vol -from withings_api.common import UnauthorizedException +from withings_api.common import NotifyAppli, UnauthorizedException import homeassistant.components.webhook as webhook +from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const from homeassistant.components.withings.common import ConfigEntryWithingsApi, DataManager from homeassistant.config import async_process_ha_core_config @@ -19,10 +22,14 @@ from homeassistant.const import ( from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from . import setup_integration from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config +from .conftest import WEBHOOK_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator def config_schema_validate(withings_config) -> dict: @@ -224,3 +231,57 @@ async def test_set_convert_unique_id_to_string(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert config_entry.unique_id == "1234" + + +async def test_data_manager_webhook_subscription( + hass: HomeAssistant, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test data manager webhook subscriptions.""" + await setup_integration(hass, config_entry) + await hass_client_no_auth() + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert withings.notify_subscribe.call_count == 4 + + webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + + withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) + withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.CIRCULATORY) + withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) + withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) + + withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) + withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) + + +@pytest.mark.parametrize( + "method", + [ + "PUT", + "HEAD", + ], +) +async def test_requests( + hass: HomeAssistant, + withings: AsyncMock, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + method: str, + disable_webhook_delay, +) -> None: + """Test we handle request methods Withings sends.""" + await setup_integration(hass, config_entry) + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + response = await client.request( + method=method, + path=urlparse(webhook_url).path, + ) + assert response.status == 200 diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6ab0fc97f4e..4cc71df80d7 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the Withings component.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from syrupy import SnapshotAssertion @@ -14,10 +14,11 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from . import MockWithings, call_webhook +from . import call_webhook, setup_integration from .common import async_get_entity_id -from .conftest import USER_ID, WEBHOOK_ID, ComponentSetup +from .conftest import USER_ID, WEBHOOK_ID +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { @@ -77,65 +78,55 @@ def async_assert_state_equals( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_default_enabled_entities( hass: HomeAssistant, - setup_integration: ComponentSetup, + withings: AsyncMock, + config_entry: MockConfigEntry, + disable_webhook_delay, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" - await setup_integration() + await setup_integration(hass, config_entry) entity_registry: EntityRegistry = er.async_get(hass) - mock = MockWithings() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - return_value=mock, - ): - client = await hass_client_no_auth() - # Assert entities should exist. - for attribute in SENSORS: - entity_id = await async_get_entity_id( - hass, attribute, USER_ID, SENSOR_DOMAIN - ) - assert entity_id - assert entity_registry.async_is_registered(entity_id) - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.SLEEP}, - client, - ) - assert resp.message_code == 0 - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, - client, - ) - assert resp.message_code == 0 + client = await hass_client_no_auth() + # Assert entities should exist. + for attribute in SENSORS: + entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN) + assert entity_id + assert entity_registry.async_is_registered(entity_id) + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.SLEEP}, + client, + ) + assert resp.message_code == 0 + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) + assert resp.message_code == 0 - assert resp.message_code == 0 + for measurement, expected in EXPECTED_DATA: + attribute = WITHINGS_MEASUREMENTS_MAP[measurement] + entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN) + state_obj = hass.states.get(entity_id) - for measurement, expected in EXPECTED_DATA: - attribute = WITHINGS_MEASUREMENTS_MAP[measurement] - entity_id = await async_get_entity_id( - hass, attribute, USER_ID, SENSOR_DOMAIN - ) - state_obj = hass.states.get(entity_id) - - async_assert_state_equals(entity_id, state_obj, expected, attribute) + async_assert_state_equals(entity_id, state_obj, expected, attribute) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( - hass: HomeAssistant, setup_integration: ComponentSetup, snapshot: SnapshotAssertion + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - await setup_integration() + await setup_integration(hass, config_entry) - mock = MockWithings() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - return_value=mock, - ): - for sensor in SENSORS: - entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) - assert hass.states.get(entity_id) == snapshot + for sensor in SENSORS: + entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) + assert hass.states.get(entity_id) == snapshot From cbb28b69436ba72256d43921a5d0faa554146456 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 11 Sep 2023 21:39:33 +0200 Subject: [PATCH 395/984] Migrate internal ZHA data to a dataclasses (#100127) * Cache device triggers on startup * reorg zha init * don't reuse gateway * don't nuke yaml configuration * review comments * Add unit tests * Do not cache device and entity registries * [WIP] Wrap ZHA data in a dataclass * [WIP] Get unit tests passing * Use a helper function for getting the gateway object to fix annotations * Remove `bridge_id` * Fix typing issues with entity references in group websocket info * Use `Platform` instead of `str` for entity platform matching * Use `get_zha_gateway` in a few more places * Fix flaky unit test * Use `slots` for ZHA data Co-authored-by: J. Nick Koston --------- Co-authored-by: David F. Mulcahey Co-authored-by: J. Nick Koston --- homeassistant/components/zha/__init__.py | 42 ++++------- .../components/zha/alarm_control_panel.py | 6 +- homeassistant/components/zha/api.py | 29 ++------ homeassistant/components/zha/backup.py | 5 +- homeassistant/components/zha/binary_sensor.py | 5 +- homeassistant/components/zha/button.py | 6 +- homeassistant/components/zha/climate.py | 5 +- homeassistant/components/zha/core/const.py | 1 - homeassistant/components/zha/core/device.py | 8 +- .../components/zha/core/discovery.py | 41 ++++++----- homeassistant/components/zha/core/endpoint.py | 6 +- homeassistant/components/zha/core/gateway.py | 68 +++++++++-------- homeassistant/components/zha/core/group.py | 45 ++++++++---- homeassistant/components/zha/core/helpers.py | 43 ++++++++--- .../components/zha/core/registries.py | 28 ++++--- homeassistant/components/zha/cover.py | 5 +- .../components/zha/device_tracker.py | 5 +- .../components/zha/device_trigger.py | 8 +- homeassistant/components/zha/diagnostics.py | 16 ++-- homeassistant/components/zha/entity.py | 9 ++- homeassistant/components/zha/fan.py | 11 +-- homeassistant/components/zha/light.py | 6 +- homeassistant/components/zha/lock.py | 5 +- homeassistant/components/zha/number.py | 5 +- homeassistant/components/zha/radio_manager.py | 5 +- homeassistant/components/zha/select.py | 5 +- homeassistant/components/zha/sensor.py | 5 +- homeassistant/components/zha/siren.py | 5 +- homeassistant/components/zha/switch.py | 5 +- homeassistant/components/zha/websocket_api.py | 73 +++++++++---------- tests/components/zha/common.py | 10 +-- tests/components/zha/conftest.py | 9 ++- tests/components/zha/test_api.py | 5 +- tests/components/zha/test_cluster_handlers.py | 3 +- tests/components/zha/test_device_action.py | 34 ++++----- tests/components/zha/test_device_trigger.py | 1 + tests/components/zha/test_diagnostics.py | 4 +- tests/components/zha/test_discover.py | 2 +- tests/components/zha/test_fan.py | 2 +- tests/components/zha/test_gateway.py | 3 +- tests/components/zha/test_light.py | 15 ++-- .../zha/test_silabs_multiprotocol.py | 5 +- tests/components/zha/test_switch.py | 2 +- tests/components/zha/test_websocket_api.py | 4 +- 44 files changed, 317 insertions(+), 288 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 662ddd080e0..bd181d82a33 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -33,9 +33,6 @@ from .core.const import ( CONF_USB_PATH, CONF_ZIGPY, DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_DEVICE_TRIGGER_CACHE, - DATA_ZHA_GATEWAY, DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, @@ -43,6 +40,7 @@ from .core.const import ( ) from .core.device import get_device_automation_triggers from .core.discovery import GROUP_PROBE +from .core.helpers import ZHAData, get_zha_data from .radio_manager import ZhaRadioManager DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) @@ -81,11 +79,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up ZHA from config.""" - hass.data[DATA_ZHA] = {} - - if DOMAIN in config: - conf = config[DOMAIN] - hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf + zha_data = ZHAData() + zha_data.yaml_config = config.get(DOMAIN, {}) + hass.data[DATA_ZHA] = zha_data return True @@ -120,14 +116,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path hass.config_entries.async_update_entry(config_entry, data=data) - zha_data = hass.data.setdefault(DATA_ZHA, {}) - config = zha_data.get(DATA_ZHA_CONFIG, {}) + zha_data = get_zha_data(hass) - for platform in PLATFORMS: - zha_data.setdefault(platform, []) - - if config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks(custom_quirks_path=config.get(CONF_CUSTOM_QUIRKS_PATH)) + if zha_data.yaml_config.get(CONF_ENABLE_QUIRKS, True): + setup_quirks( + custom_quirks_path=zha_data.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH) + ) # temporary code to remove the ZHA storage file from disk. # this will be removed in 2022.10.0 @@ -139,8 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("ZHA storage file does not exist or was already removed") # Load and cache device trigger information early - zha_data.setdefault(DATA_ZHA_DEVICE_TRIGGER_CACHE, {}) - device_registry = dr.async_get(hass) radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) @@ -154,14 +146,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if dev_entry is None: continue - zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE][dev_entry.id] = ( + zha_data.device_trigger_cache[dev_entry.id] = ( str(dev.ieee), get_device_automation_triggers(dev), ) - _LOGGER.debug("Trigger cache: %s", zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE]) + _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - zha_gateway = ZHAGateway(hass, config, config_entry) + zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) async def async_zha_shutdown(): """Handle shutdown tasks.""" @@ -172,7 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # be in when we get here in failure cases with contextlib.suppress(KeyError): for platform in PLATFORMS: - del hass.data[DATA_ZHA][platform] + del zha_data.platforms[platform] config_entry.async_on_unload(async_zha_shutdown) @@ -212,10 +204,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - try: - del hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - except KeyError: - return False + zha_data = get_zha_data(hass) + zha_data.gateway = None GROUP_PROBE.cleanup() websocket_api.async_unload_api(hass) @@ -241,7 +231,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]}, } - baudrate = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}).get(CONF_BAUDRATE) + baudrate = get_zha_data(hass).yaml_config.get(CONF_BAUDRATE) if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: data[CONF_DEVICE][CONF_BAUDRATE] = baudrate diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index b6794e909d8..21cacfa5dd4 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -35,11 +35,10 @@ from .core.const import ( CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, ZHA_ALARM_OPTIONS, ) -from .core.helpers import async_get_zha_config_value +from .core.helpers import async_get_zha_config_value, get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -65,7 +64,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation alarm control panel from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.ALARM_CONTROL_PANEL] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.ALARM_CONTROL_PANEL] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 3d44103e225..f63fb9d09de 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -9,33 +9,22 @@ from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from zigpy.types import Channels from zigpy.util import pick_optimal_channel -from .core.const import ( - CONF_RADIO_TYPE, - DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_GATEWAY, - DOMAIN, - RadioType, -) +from .core.const import CONF_RADIO_TYPE, DOMAIN, RadioType from .core.gateway import ZHAGateway +from .core.helpers import get_zha_data, get_zha_gateway if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -def _get_gateway(hass: HomeAssistant) -> ZHAGateway: - """Get a reference to the ZHA gateway device.""" - return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: """Find the singleton ZHA config entry, if one exists.""" # If ZHA is already running, use its config entry try: - zha_gateway = _get_gateway(hass) - except KeyError: + zha_gateway = get_zha_gateway(hass) + except ValueError: pass else: return zha_gateway.config_entry @@ -51,8 +40,7 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: """Get the network settings for the currently active ZHA network.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - app = zha_gateway.application_controller + app = get_zha_gateway(hass).application_controller return NetworkBackup( node_info=app.state.node_info, @@ -67,7 +55,7 @@ async def async_get_last_network_settings( if config_entry is None: config_entry = _get_config_entry(hass) - config = hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + config = get_zha_data(hass).yaml_config zha_gateway = ZHAGateway(hass, config, config_entry) app_controller_cls, app_config = zha_gateway.get_application_controller_data() @@ -91,7 +79,7 @@ async def async_get_network_settings( try: return async_get_active_network_settings(hass) - except KeyError: + except ValueError: return await async_get_last_network_settings(hass, config_entry) @@ -120,8 +108,7 @@ async def async_change_channel( ) -> None: """Migrate the ZHA network to a new channel.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - app = zha_gateway.application_controller + app = get_zha_gateway(hass).application_controller if new_channel == "auto": channel_energy = await app.energy_scan( diff --git a/homeassistant/components/zha/backup.py b/homeassistant/components/zha/backup.py index 89d5294e1c4..e125a8085f6 100644 --- a/homeassistant/components/zha/backup.py +++ b/homeassistant/components/zha/backup.py @@ -3,8 +3,7 @@ import logging from homeassistant.core import HomeAssistant -from .core import ZHAGateway -from .core.const import DATA_ZHA, DATA_ZHA_GATEWAY +from .core.helpers import get_zha_gateway _LOGGER = logging.getLogger(__name__) @@ -13,7 +12,7 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.debug("Performing coordinator backup") - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) await zha_gateway.application_controller.backups.create_backup(load_devices=True) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 50cfb783370..c32bd5eeb67 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -26,10 +26,10 @@ from .core.const import ( CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ZONE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -65,7 +65,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation binary sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.BINARY_SENSOR] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.BINARY_SENSOR] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 7a4132115b8..4114a3dea7c 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -14,7 +14,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.const import CLUSTER_HANDLER_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES +from .core.const import CLUSTER_HANDLER_IDENTIFY, SIGNAL_ADD_ENTITIES +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -38,7 +39,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation button from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.BUTTON] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.BUTTON] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index cf868ef8b7b..5cbe2684ab4 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -45,13 +45,13 @@ from .core import discovery from .core.const import ( CLUSTER_HANDLER_FAN, CLUSTER_HANDLER_THERMOSTAT, - DATA_ZHA, PRESET_COMPLEX, PRESET_SCHEDULE, PRESET_TEMP_MANUAL, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -115,7 +115,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.CLIMATE] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.CLIMATE] unsub = async_dispatcher_connect( hass, SIGNAL_ADD_ENTITIES, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9569fc49659..b37fa7ffe6d 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -184,7 +184,6 @@ CUSTOM_CONFIGURATION = "custom_configuration" DATA_DEVICE_CONFIG = "zha_device_config" DATA_ZHA = "zha" DATA_ZHA_CONFIG = "config" -DATA_ZHA_BRIDGE_ID = "zha_bridge_id" DATA_ZHA_CORE_EVENTS = "zha_core_events" DATA_ZHA_DEVICE_TRIGGER_CACHE = "zha_device_trigger_cache" DATA_ZHA_GATEWAY = "zha_gateway" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 60bf78e516c..8f5b087f068 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -25,6 +25,7 @@ from homeassistant.backports.functools import cached_property from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -420,7 +421,9 @@ class ZHADevice(LogMixin): """Update device sw version.""" if self.device_id is None: return - self._zha_gateway.ha_device_registry.async_update_device( + + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( self.device_id, sw_version=f"0x{sw_version:08x}" ) @@ -658,7 +661,8 @@ class ZHADevice(LogMixin): ) device_info[ATTR_ENDPOINT_NAMES] = names - reg_device = self.gateway.ha_device_registry.async_get(self.device_id) + device_registry = dr.async_get(self.hass) + reg_device = device_registry.async_get(self.device_id) if reg_device is not None: device_info["user_given_name"] = reg_device.name_by_user device_info["device_reg_id"] = reg_device.id diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 92b68bdb159..a56e7044d3a 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -4,10 +4,11 @@ from __future__ import annotations from collections import Counter from collections.abc import Callable import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from homeassistant.const import CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -49,12 +50,12 @@ from .cluster_handlers import ( # noqa: F401 security, smartenergy, ) +from .helpers import get_zha_data, get_zha_gateway if TYPE_CHECKING: from ..entity import ZhaEntity from .device import ZHADevice from .endpoint import Endpoint - from .gateway import ZHAGateway from .group import ZHAGroup _LOGGER = logging.getLogger(__name__) @@ -113,6 +114,8 @@ class ProbeEndpoint: platform = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) if platform and platform in zha_const.PLATFORMS: + platform = cast(Platform, platform) + cluster_handlers = endpoint.unclaimed_cluster_handlers() platform_entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( platform, @@ -263,9 +266,7 @@ class ProbeEndpoint: def initialize(self, hass: HomeAssistant) -> None: """Update device overrides config.""" - zha_config: ConfigType = hass.data[zha_const.DATA_ZHA].get( - zha_const.DATA_ZHA_CONFIG, {} - ) + zha_config = get_zha_data(hass).yaml_config if overrides := zha_config.get(zha_const.CONF_DEVICE_CONFIG): self._device_configs.update(overrides) @@ -297,9 +298,7 @@ class GroupProbe: @callback def _reprobe_group(self, group_id: int) -> None: """Reprobe a group for entities after its members change.""" - zha_gateway: ZHAGateway = self._hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] + zha_gateway = get_zha_gateway(self._hass) if (zha_group := zha_gateway.groups.get(group_id)) is None: return self.discover_group_entities(zha_group) @@ -321,14 +320,14 @@ class GroupProbe: if not entity_domains: return - zha_gateway: ZHAGateway = self._hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] + zha_data = get_zha_data(self._hass) + zha_gateway = get_zha_gateway(self._hass) + for domain in entity_domains: entity_class = zha_regs.ZHA_ENTITIES.get_group_entity(domain) if entity_class is None: continue - self._hass.data[zha_const.DATA_ZHA][domain].append( + zha_data.platforms[domain].append( ( entity_class, ( @@ -342,24 +341,26 @@ class GroupProbe: async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES) @staticmethod - def determine_entity_domains(hass: HomeAssistant, group: ZHAGroup) -> list[str]: + def determine_entity_domains( + hass: HomeAssistant, group: ZHAGroup + ) -> list[Platform]: """Determine the entity domains for this group.""" - entity_domains: list[str] = [] - zha_gateway: ZHAGateway = hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] - all_domain_occurrences = [] + entity_registry = er.async_get(hass) + + entity_domains: list[Platform] = [] + all_domain_occurrences: list[Platform] = [] + for member in group.members: if member.device.is_coordinator: continue entities = async_entries_for_device( - zha_gateway.ha_entity_registry, + entity_registry, member.device.device_id, include_disabled_entities=True, ) all_domain_occurrences.extend( [ - entity.domain + cast(Platform, entity.domain) for entity in entities if entity.domain in zha_regs.GROUP_ENTITY_DOMAINS ] diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index bdef5ac46af..c87ee60d6b3 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -16,6 +16,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from . import const, discovery, registries from .cluster_handlers import ClusterHandler from .cluster_handlers.general import MultistateInput +from .helpers import get_zha_data if TYPE_CHECKING: from .cluster_handlers import ClientClusterHandler @@ -195,7 +196,7 @@ class Endpoint: def async_new_entity( self, - platform: Platform | str, + platform: Platform, entity_class: CALLABLE_T, unique_id: str, cluster_handlers: list[ClusterHandler], @@ -206,7 +207,8 @@ class Endpoint: if self.device.status == DeviceStatus.INITIALIZED: return - self.device.hass.data[const.DATA_ZHA][platform].append( + zha_data = get_zha_data(self.device.hass) + zha_data.platforms[platform].append( (entity_class, (unique_id, self.device, cluster_handlers)) ) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 5cc2cd9a4b9..5fe84005d7a 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,9 +46,6 @@ from .const import ( CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, - DATA_ZHA, - DATA_ZHA_BRIDGE_ID, - DATA_ZHA_GATEWAY, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, @@ -87,6 +84,7 @@ from .const import ( ) from .device import DeviceStatus, ZHADevice from .group import GroupMember, ZHAGroup +from .helpers import get_zha_data from .registries import GROUP_ENTITY_DOMAINS if TYPE_CHECKING: @@ -123,8 +121,6 @@ class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" # -- Set in async_initialize -- - ha_device_registry: dr.DeviceRegistry - ha_entity_registry: er.EntityRegistry application_controller: ControllerApplication radio_description: str @@ -132,7 +128,7 @@ class ZHAGateway: self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry ) -> None: """Initialize the gateway.""" - self._hass = hass + self.hass = hass self._config = config self._devices: dict[EUI64, ZHADevice] = {} self._groups: dict[int, ZHAGroup] = {} @@ -159,7 +155,7 @@ class ZHAGateway: app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( CONF_DATABASE, - self._hass.config.path(DEFAULT_DATABASE_NAME), + self.hass.config.path(DEFAULT_DATABASE_NAME), ) app_config[CONF_DATABASE] = database app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] @@ -191,11 +187,8 @@ class ZHAGateway: async def async_initialize(self) -> None: """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) + discovery.PROBE.initialize(self.hass) + discovery.GROUP_PROBE.initialize(self.hass) app_controller_cls, app_config = self.get_application_controller_data() self.application_controller = await app_controller_cls.new( @@ -225,8 +218,8 @@ class ZHAGateway: else: break - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) + zha_data = get_zha_data(self.hass) + zha_data.gateway = self self.coordinator_zha_device = self._async_get_or_create_device( self._find_coordinator_device(), restored=True @@ -301,7 +294,7 @@ class ZHAGateway: # background the fetching of state for mains powered devices self.config_entry.async_create_background_task( - self._hass, fetch_updated_state(), "zha.gateway-fetch_updated_state" + self.hass, fetch_updated_state(), "zha.gateway-fetch_updated_state" ) def device_joined(self, device: zigpy.device.Device) -> None: @@ -311,7 +304,7 @@ class ZHAGateway: address """ async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_JOINED, @@ -327,7 +320,7 @@ class ZHAGateway: """Handle a device initialization without quirks loaded.""" manuf = device.manufacturer async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_RAW_INIT, @@ -344,7 +337,7 @@ class ZHAGateway: def device_initialized(self, device: zigpy.device.Device) -> None: """Handle device joined and basic information discovered.""" - self._hass.async_create_task(self.async_device_initialized(device)) + self.hass.async_create_task(self.async_device_initialized(device)) def device_left(self, device: zigpy.device.Device) -> None: """Handle device leaving the network.""" @@ -359,7 +352,7 @@ class ZHAGateway: zha_group.info("group_member_removed - endpoint: %s", endpoint) self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_REMOVED) async_dispatcher_send( - self._hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" + self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" ) def group_member_added( @@ -371,7 +364,7 @@ class ZHAGateway: zha_group.info("group_member_added - endpoint: %s", endpoint) self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_ADDED) async_dispatcher_send( - self._hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" + self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" ) if len(zha_group.members) == 2: # we need to do this because there wasn't already @@ -399,7 +392,7 @@ class ZHAGateway: zha_group = self._groups.get(zigpy_group.group_id) if zha_group is not None: async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: gateway_message_type, @@ -416,9 +409,11 @@ class ZHAGateway: remove_tasks.append(entity_ref.remove_future) if remove_tasks: await asyncio.wait(remove_tasks) - reg_device = self.ha_device_registry.async_get(device.device_id) + + device_registry = dr.async_get(self.hass) + reg_device = device_registry.async_get(device.device_id) if reg_device is not None: - self.ha_device_registry.async_remove_device(reg_device.id) + device_registry.async_remove_device(reg_device.id) def device_removed(self, device: zigpy.device.Device) -> None: """Handle device being removed from the network.""" @@ -427,14 +422,14 @@ class ZHAGateway: if zha_device is not None: device_info = zha_device.zha_device_info zha_device.async_cleanup_handles() - async_dispatcher_send(self._hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") - self._hass.async_create_task( + async_dispatcher_send(self.hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") + self.hass.async_create_task( self._async_remove_device(zha_device, entity_refs), "ZHAGateway._async_remove_device", ) if device_info is not None: async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_REMOVED, @@ -488,9 +483,10 @@ class ZHAGateway: ] # then we get all group entity entries tied to the coordinator + entity_registry = er.async_get(self.hass) assert self.coordinator_zha_device all_group_entity_entries = er.async_entries_for_device( - self.ha_entity_registry, + entity_registry, self.coordinator_zha_device.device_id, include_disabled_entities=True, ) @@ -508,7 +504,7 @@ class ZHAGateway: _LOGGER.debug( "cleaning up entity registry entry for entity: %s", entry.entity_id ) - self.ha_entity_registry.async_remove(entry.entity_id) + entity_registry.async_remove(entry.entity_id) @property def coordinator_ieee(self) -> EUI64: @@ -582,9 +578,11 @@ class ZHAGateway: ) -> ZHADevice: """Get or create a ZHA device.""" if (zha_device := self._devices.get(zigpy_device.ieee)) is None: - zha_device = ZHADevice.new(self._hass, zigpy_device, self, restored) + zha_device = ZHADevice.new(self.hass, zigpy_device, self, restored) self._devices[zigpy_device.ieee] = zha_device - device_registry_device = self.ha_device_registry.async_get_or_create( + + device_registry = dr.async_get(self.hass) + device_registry_device = device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, connections={(dr.CONNECTION_ZIGBEE, str(zha_device.ieee))}, identifiers={(DOMAIN, str(zha_device.ieee))}, @@ -600,7 +598,7 @@ class ZHAGateway: """Get or create a ZHA group.""" zha_group = self._groups.get(zigpy_group.group_id) if zha_group is None: - zha_group = ZHAGroup(self._hass, self, zigpy_group) + zha_group = ZHAGroup(self.hass, self, zigpy_group) self._groups[zigpy_group.group_id] = zha_group return zha_group @@ -645,7 +643,7 @@ class ZHAGateway: device_info = zha_device.zha_device_info device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.INITIALIZED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -659,7 +657,7 @@ class ZHAGateway: await zha_device.async_configure() device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -667,7 +665,7 @@ class ZHAGateway: }, ) await zha_device.async_initialize(from_cache=False) - async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) + async_dispatcher_send(self.hass, SIGNAL_ADD_ENTITIES) async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: _LOGGER.debug( @@ -681,7 +679,7 @@ class ZHAGateway: device_info = zha_device.device_info device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index ebea2f4ac41..519668052e0 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -11,6 +11,7 @@ import zigpy.group from zigpy.types.named import EUI64 from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import async_entries_for_device from .helpers import LogMixin @@ -32,8 +33,8 @@ class GroupMember(NamedTuple): class GroupEntityReference(NamedTuple): """Reference to a group entity.""" - name: str - original_name: str + name: str | None + original_name: str | None entity_id: int @@ -80,20 +81,30 @@ class ZHAGroupMember(LogMixin): @property def associated_entities(self) -> list[dict[str, Any]]: """Return the list of entities that were derived from this endpoint.""" - ha_entity_registry = self.device.gateway.ha_entity_registry + entity_registry = er.async_get(self._zha_device.hass) zha_device_registry = self.device.gateway.device_registry - return [ - GroupEntityReference( - ha_entity_registry.async_get(entity_ref.reference_id).name, - ha_entity_registry.async_get(entity_ref.reference_id).original_name, - entity_ref.reference_id, - )._asdict() - for entity_ref in zha_device_registry.get(self.device.ieee) - if list(entity_ref.cluster_handlers.values())[ - 0 - ].cluster.endpoint.endpoint_id - == self.endpoint_id - ] + + entity_info = [] + + for entity_ref in zha_device_registry.get(self.device.ieee): + entity = entity_registry.async_get(entity_ref.reference_id) + handler = list(entity_ref.cluster_handlers.values())[0] + + if ( + entity is None + or handler.cluster.endpoint.endpoint_id != self.endpoint_id + ): + continue + + entity_info.append( + GroupEntityReference( + name=entity.name, + original_name=entity.original_name, + entity_id=entity_ref.reference_id, + )._asdict() + ) + + return entity_info async def async_remove_from_group(self) -> None: """Remove the device endpoint from the provided zigbee group.""" @@ -204,12 +215,14 @@ class ZHAGroup(LogMixin): def get_domain_entity_ids(self, domain: str) -> list[str]: """Return entity ids from the entity domain for this group.""" + entity_registry = er.async_get(self.hass) domain_entity_ids: list[str] = [] + for member in self.members: if member.device.is_coordinator: continue entities = async_entries_for_device( - self._zha_gateway.ha_entity_registry, + entity_registry, member.device.device_id, include_disabled_entities=True, ) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 7b0d062738b..4df546b449c 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -7,7 +7,9 @@ from __future__ import annotations import asyncio import binascii +import collections from collections.abc import Callable, Iterator +import dataclasses from dataclasses import dataclass import enum import functools @@ -26,16 +28,12 @@ from zigpy.zcl.foundation import CommandSchema import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType -from .const import ( - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, - CUSTOM_CONFIGURATION, - DATA_ZHA, - DATA_ZHA_GATEWAY, -) +from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, DATA_ZHA from .registries import BINDABLE_CLUSTERS if TYPE_CHECKING: @@ -221,7 +219,7 @@ def async_get_zha_config_value( def async_cluster_exists(hass, cluster_id, skip_coordinator=True): """Determine if a device containing the specified in cluster is paired.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) zha_devices = zha_gateway.devices.values() for zha_device in zha_devices: if skip_coordinator and zha_device.is_coordinator: @@ -244,7 +242,7 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: if not registry_device: _LOGGER.error("Device id `%s` not found in registry", device_id) raise KeyError(f"Device id `{device_id}` not found in registry.") - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) try: ieee_address = list(registry_device.identifiers)[0][1] ieee = zigpy.types.EUI64.convert(ieee_address) @@ -421,3 +419,30 @@ def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, bytes]: return ieee, install_code raise vol.Invalid(f"couldn't convert qr code: {qr_code}") + + +@dataclasses.dataclass(kw_only=True, slots=True) +class ZHAData: + """ZHA component data stored in `hass.data`.""" + + yaml_config: ConfigType = dataclasses.field(default_factory=dict) + platforms: collections.defaultdict[Platform, list] = dataclasses.field( + default_factory=lambda: collections.defaultdict(list) + ) + gateway: ZHAGateway | None = dataclasses.field(default=None) + device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field( + default_factory=dict + ) + + +def get_zha_data(hass: HomeAssistant) -> ZHAData: + """Get the global ZHA data object.""" + return hass.data.get(DATA_ZHA, ZHAData()) + + +def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get the ZHA gateway object.""" + if (zha_gateway := get_zha_data(hass).gateway) is None: + raise ValueError("No gateway object exists") + + return zha_gateway diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 713d10ddf70..74f724bdc49 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -269,15 +269,15 @@ class ZHAEntityRegistry: def __init__(self) -> None: """Initialize Registry instance.""" self._strict_registry: dict[ - str, dict[MatchRule, type[ZhaEntity]] + Platform, dict[MatchRule, type[ZhaEntity]] ] = collections.defaultdict(dict) self._multi_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) self._config_diagnostic_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) @@ -288,7 +288,7 @@ class ZHAEntityRegistry: def get_entity( self, - component: str, + component: Platform, manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], @@ -310,10 +310,12 @@ class ZHAEntityRegistry: model: str, cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + ) -> tuple[ + dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] + ]: """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" result: dict[ - str, list[EntityClassAndClusterHandlers] + Platform, list[EntityClassAndClusterHandlers] ] = collections.defaultdict(list) all_claimed: set[ClusterHandler] = set() for component, stop_match_groups in self._multi_entity_registry.items(): @@ -341,10 +343,12 @@ class ZHAEntityRegistry: model: str, cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + ) -> tuple[ + dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] + ]: """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" result: dict[ - str, list[EntityClassAndClusterHandlers] + Platform, list[EntityClassAndClusterHandlers] ] = collections.defaultdict(list) all_claimed: set[ClusterHandler] = set() for ( @@ -375,7 +379,7 @@ class ZHAEntityRegistry: def strict_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -406,7 +410,7 @@ class ZHAEntityRegistry: def multipass_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -441,7 +445,7 @@ class ZHAEntityRegistry: def config_diagnostic_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -475,7 +479,7 @@ class ZHAEntityRegistry: return decorator def group_match( - self, component: str + self, component: Platform ) -> Callable[[_ZhaGroupEntityT], _ZhaGroupEntityT]: """Decorate a group match rule.""" diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 0d7062173ca..f2aed0390f3 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -33,11 +33,11 @@ from .core.const import ( CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_SHADE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -56,7 +56,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation cover from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.COVER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.COVER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index bda346624dd..ea27c58eb19 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -15,10 +15,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CLUSTER_HANDLER_POWER_CONFIGURATION, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity from .sensor import Battery @@ -32,7 +32,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation device tracker from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.DEVICE_TRACKER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.DEVICE_TRACKER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 7a479443377..a2ae734b8fc 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -14,8 +14,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN as ZHA_DOMAIN -from .core.const import DATA_ZHA, DATA_ZHA_DEVICE_TRIGGER_CACHE, ZHA_EVENT -from .core.helpers import async_get_zha_device +from .core.const import ZHA_EVENT +from .core.helpers import async_get_zha_device, get_zha_data CONF_SUBTYPE = "subtype" DEVICE = "device" @@ -32,13 +32,13 @@ def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str, # First, try checking to see if the device itself is accessible try: zha_device = async_get_zha_device(hass, device_id) - except KeyError: + except ValueError: pass else: return str(zha_device.ieee), zha_device.device_automation_triggers # If not, check the trigger cache but allow any `KeyError`s to propagate - return hass.data[DATA_ZHA][DATA_ZHA_DEVICE_TRIGGER_CACHE][device_id] + return get_zha_data(hass).device_trigger_cache[device_id] async def async_validate_trigger_config( diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 966f35fe98b..0fa1de5ff0e 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -25,14 +25,10 @@ from .core.const import ( ATTR_PROFILE_ID, ATTR_VALUE, CONF_ALARM_MASTER_CODE, - DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_GATEWAY, UNKNOWN, ) from .core.device import ZHADevice -from .core.gateway import ZHAGateway -from .core.helpers import async_get_zha_device +from .core.helpers import async_get_zha_device, get_zha_data, get_zha_gateway KEYS_TO_REDACT = { ATTR_IEEE, @@ -66,18 +62,18 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) - gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_data = get_zha_data(hass) + app = get_zha_gateway(hass).application_controller - energy_scan = await gateway.application_controller.energy_scan( + energy_scan = await app.energy_scan( channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 ) return async_redact_data( { - "config": config, + "config": zha_data.yaml_config, "config_entry": config_entry.as_dict(), - "application_state": shallow_asdict(gateway.application_controller.state), + "application_state": shallow_asdict(app.state), "energy_scan": { channel: 100 * energy / 255 for channel, energy in energy_scan.items() }, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index f2b16a37834..5722d91116a 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -26,14 +26,12 @@ from homeassistant.helpers.typing import EventType from .core.const import ( ATTR_MANUFACTURER, ATTR_MODEL, - DATA_ZHA, - DATA_ZHA_BRIDGE_ID, DOMAIN, SIGNAL_GROUP_ENTITY_REMOVED, SIGNAL_GROUP_MEMBERSHIP_CHANGE, SIGNAL_REMOVE, ) -from .core.helpers import LogMixin +from .core.helpers import LogMixin, get_zha_gateway if TYPE_CHECKING: from .core.cluster_handlers import ClusterHandler @@ -83,13 +81,16 @@ class BaseZhaEntity(LogMixin, entity.Entity): """Return a device description for device registry.""" zha_device_info = self._zha_device.device_info ieee = zha_device_info["ieee"] + + zha_gateway = get_zha_gateway(self.hass) + return DeviceInfo( connections={(CONNECTION_ZIGBEE, ieee)}, identifiers={(DOMAIN, ieee)}, manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), + via_device=(DOMAIN, zha_gateway.coordinator_ieee), ) @callback diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index a24272c9a7a..73b128db109 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -28,12 +28,8 @@ from homeassistant.util.percentage import ( from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions -from .core.const import ( - CLUSTER_HANDLER_FAN, - DATA_ZHA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, -) +from .core.const import CLUSTER_HANDLER_FAN, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -65,7 +61,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation fan from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.FAN] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.FAN] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2ec42431498..967d0fc9134 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -47,13 +47,12 @@ from .core.const import ( CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, CONF_GROUP_MEMBERS_ASSUME_STATE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, ZHA_OPTIONS, ) -from .core.helpers import LogMixin, async_get_zha_config_value +from .core.helpers import LogMixin, async_get_zha_config_value, get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -97,7 +96,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation light from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.LIGHT] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.LIGHT] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 1e68e95c881..9bac9a59a38 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -20,10 +20,10 @@ from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( CLUSTER_HANDLER_DOORLOCK, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -45,7 +45,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.LOCK] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.LOCK] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index c12060eb2a8..b6876155312 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -20,10 +20,10 @@ from .core.const import ( CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -258,7 +258,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Analog Output from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.NUMBER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.NUMBER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index df30a85cd7b..ca030600751 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -26,12 +26,11 @@ from .core.const import ( CONF_DATABASE, CONF_RADIO_TYPE, CONF_ZIGPY, - DATA_ZHA, - DATA_ZHA_CONFIG, DEFAULT_DATABASE_NAME, EZSP_OVERWRITE_EUI64, RadioType, ) +from .core.helpers import get_zha_data # Only the common radio types will be autoprobed, ordered by new device popularity. # XBee takes too long to probe since it scans through all possible bauds and likely has @@ -145,7 +144,7 @@ class ZhaRadioManager: """Connect to the radio with the current config and then clean up.""" assert self.radio_type is not None - config = self.hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + config = get_zha_data(self.hass).yaml_config app_config = config.get(CONF_ZIGPY, {}).copy() database_path = config.get( diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 018f24675e7..fa2e124fd05 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -23,11 +23,11 @@ from .core.const import ( CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, Strobe, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -48,7 +48,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SELECT] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SELECT] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 535733230b9..1e166675b5b 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -57,10 +57,10 @@ from .core.const import ( CLUSTER_HANDLER_SOIL_MOISTURE, CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .entity import ZhaEntity @@ -99,7 +99,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SENSOR] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SENSOR] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index a4c699d515b..86cadb62519 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -25,7 +25,6 @@ from .core import discovery from .core.cluster_handlers.security import IasWd from .core.const import ( CLUSTER_HANDLER_IAS_WD, - DATA_ZHA, SIGNAL_ADD_ENTITIES, WARNING_DEVICE_MODE_BURGLAR, WARNING_DEVICE_MODE_EMERGENCY, @@ -39,6 +38,7 @@ from .core.const import ( WARNING_DEVICE_STROBE_NO, Strobe, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -56,7 +56,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SIREN] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SIREN] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 8707dda629f..eff8f727c1c 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -20,10 +20,10 @@ from .core.const import ( CLUSTER_HANDLER_BASIC, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -46,7 +46,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation switch from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SWITCH] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SWITCH] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 97862bd36f0..51941248f03 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -16,6 +16,7 @@ import zigpy.zdo.types as zdo_types from homeassistant.components import websocket_api from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service @@ -52,8 +53,6 @@ from .core.const import ( CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, - DATA_ZHA, - DATA_ZHA_GATEWAY, DOMAIN, EZSP_OVERWRITE_EUI64, GROUP_ID, @@ -77,6 +76,7 @@ from .core.helpers import ( cluster_command_schema_to_vol_schema, convert_install_code, get_matched_clusters, + get_zha_gateway, qr_to_install_code, ) @@ -301,7 +301,7 @@ async def websocket_permit_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Permit ZHA zigbee devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) duration: int = msg[ATTR_DURATION] ieee: EUI64 | None = msg.get(ATTR_IEEE) @@ -348,7 +348,7 @@ async def websocket_get_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) devices = [device.zha_device_info for device in zha_gateway.devices.values()] connection.send_result(msg[ID], devices) @@ -357,7 +357,8 @@ async def websocket_get_devices( def _get_entity_name( zha_gateway: ZHAGateway, entity_ref: EntityReference ) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + entity_registry = er.async_get(zha_gateway.hass) + entry = entity_registry.async_get(entity_ref.reference_id) return entry.name if entry else None @@ -365,7 +366,8 @@ def _get_entity_name( def _get_entity_original_name( zha_gateway: ZHAGateway, entity_ref: EntityReference ) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + entity_registry = er.async_get(zha_gateway.hass) + entry = entity_registry.async_get(entity_ref.reference_id) return entry.original_name if entry else None @@ -376,7 +378,7 @@ async def websocket_get_groupable_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices that can be grouped.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) devices = [device for device in zha_gateway.devices.values() if device.is_groupable] groupable_devices = [] @@ -414,7 +416,7 @@ async def websocket_get_groups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) groups = [group.group_info for group in zha_gateway.groups.values()] connection.send_result(msg[ID], groups) @@ -431,7 +433,7 @@ async def websocket_get_device( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] if not (zha_device := zha_gateway.devices.get(ieee)): @@ -458,7 +460,7 @@ async def websocket_get_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] if not (zha_group := zha_gateway.groups.get(group_id)): @@ -487,7 +489,7 @@ async def websocket_add_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Add a new ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_name: str = msg[GROUP_NAME] group_id: int | None = msg.get(GROUP_ID) members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) @@ -508,7 +510,7 @@ async def websocket_remove_groups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove the specified ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_ids: list[int] = msg[GROUP_IDS] if len(group_ids) > 1: @@ -535,7 +537,7 @@ async def websocket_add_group_members( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Add members to a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] members: list[GroupMember] = msg[ATTR_MEMBERS] @@ -565,7 +567,7 @@ async def websocket_remove_group_members( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove members from a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] members: list[GroupMember] = msg[ATTR_MEMBERS] @@ -594,7 +596,7 @@ async def websocket_reconfigure_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] device: ZHADevice | None = zha_gateway.get_device(ieee) @@ -629,7 +631,7 @@ async def websocket_update_topology( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Update the ZHA network topology.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) hass.async_create_task(zha_gateway.application_controller.topology.scan()) @@ -645,7 +647,7 @@ async def websocket_device_clusters( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of device clusters.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] zha_device = zha_gateway.get_device(ieee) response_clusters = [] @@ -689,7 +691,7 @@ async def websocket_device_cluster_attributes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster attributes.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -736,7 +738,7 @@ async def websocket_device_cluster_commands( """Return a list of cluster commands.""" import voluptuous_serialize # pylint: disable=import-outside-toplevel - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -806,7 +808,7 @@ async def websocket_read_zigbee_cluster_attributes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Read zigbee attribute for cluster on ZHA entity.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -860,7 +862,7 @@ async def websocket_get_bindable_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_IEEE] source_device = zha_gateway.get_device(source_ieee) @@ -894,7 +896,7 @@ async def websocket_bind_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] await async_binding_operation( @@ -923,7 +925,7 @@ async def websocket_unbind_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove a direct binding between devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] await async_binding_operation( @@ -953,7 +955,7 @@ async def websocket_bind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = get_gateway(hass) + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] @@ -977,7 +979,7 @@ async def websocket_unbind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Unbind a device from a group.""" - zha_gateway: ZHAGateway = get_gateway(hass) + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] @@ -987,11 +989,6 @@ async def websocket_unbind_group( connection.send_result(msg[ID]) -def get_gateway(hass: HomeAssistant) -> ZHAGateway: - """Return Gateway, mainly as fixture for mocking during testing.""" - return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - async def async_binding_operation( zha_gateway: ZHAGateway, source_ieee: EUI64, @@ -1047,7 +1044,7 @@ async def websocket_get_configuration( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) import voluptuous_serialize # pylint: disable=import-outside-toplevel def custom_serializer(schema: Any) -> Any: @@ -1094,7 +1091,7 @@ async def websocket_update_zha_configuration( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Update the ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) options = zha_gateway.config_entry.options data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} @@ -1141,7 +1138,7 @@ async def websocket_get_network_settings( ) -> None: """Get ZHA network settings.""" backup = async_get_active_network_settings(hass) - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) connection.send_result( msg[ID], { @@ -1159,7 +1156,7 @@ async def websocket_list_network_backups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller # Serialize known backups @@ -1175,7 +1172,7 @@ async def websocket_create_network_backup( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Create a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller # This can take 5-30s @@ -1202,7 +1199,7 @@ async def websocket_restore_network_backup( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Restore a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller backup = msg["backup"] @@ -1240,7 +1237,7 @@ async def websocket_change_channel( @callback def async_load_api(hass: HomeAssistant) -> None: """Set up the web socket API.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller async def permit(service: ServiceCall) -> None: @@ -1278,7 +1275,7 @@ def async_load_api(hass: HomeAssistant) -> None: async def remove(service: ServiceCall) -> None: """Remove a node from the network.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = service.data[ATTR_IEEE] zha_device: ZHADevice | None = zha_gateway.get_device(ieee) if zha_device is not None and zha_device.is_active_coordinator: diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index db1da3721ee..44155d741b7 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -9,7 +9,10 @@ import zigpy.zcl import zigpy.zcl.foundation as zcl_f import homeassistant.components.zha.core.const as zha_const -from homeassistant.components.zha.core.helpers import async_get_zha_config_value +from homeassistant.components.zha.core.helpers import ( + async_get_zha_config_value, + get_zha_gateway, +) from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -85,11 +88,6 @@ def update_attribute_cache(cluster): cluster.handle_message(hdr, msg) -def get_zha_gateway(hass): - """Return ZHA gateway from hass.data.""" - return hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] - - def make_attribute(attrid, value, status=0): """Make an attribute.""" attr = zcl_f.Attribute() diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 7d391872a77..e7dc7316f73 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -22,9 +22,10 @@ import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.setup import async_setup_component -from . import common +from .common import patch_cluster as common_patch_cluster from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -277,7 +278,7 @@ def zigpy_device_mock(zigpy_app_controller): for cluster in itertools.chain( endpoint.in_clusters.values(), endpoint.out_clusters.values() ): - common.patch_cluster(cluster) + common_patch_cluster(cluster) if attributes is not None: for ep_id, clusters in attributes.items(): @@ -305,7 +306,7 @@ def zha_device_joined(hass, setup_zha): if setup_zha: await setup_zha_fixture() - zha_gateway = common.get_zha_gateway(hass) + zha_gateway = get_zha_gateway(hass) zha_gateway.application_controller.devices[zigpy_dev.ieee] = zigpy_dev await zha_gateway.async_device_initialized(zigpy_dev) await hass.async_block_till_done() @@ -329,7 +330,7 @@ def zha_device_restored(hass, zigpy_app_controller, setup_zha): if setup_zha: await setup_zha_fixture() - zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) return zha_gateway.get_device(zigpy_dev.ieee) return _zha_device diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c2cb16efcc8..89742fb1e49 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -11,6 +11,7 @@ import zigpy.state from homeassistant.components import zha from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.core import HomeAssistant if TYPE_CHECKING: @@ -40,7 +41,7 @@ async def test_async_get_network_settings_inactive( """Test reading settings with an inactive ZHA installation.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) backup = zigpy.backups.NetworkBackup() @@ -70,7 +71,7 @@ async def test_async_get_network_settings_missing( """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await gateway.config_entry.async_unload(hass) # Network settings were never loaded for whatever reason diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 7e0e8eaab85..24162296cd5 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -20,11 +20,12 @@ import homeassistant.components.zha.core.cluster_handlers as cluster_handlers import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.endpoint import Endpoint +from homeassistant.components.zha.core.helpers import get_zha_gateway import homeassistant.components.zha.core.registries as registries from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .common import get_zha_gateway, make_zcl_header +from .common import make_zcl_header from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_capture_events diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 31ffe9449e2..229fde89f15 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -108,21 +108,19 @@ async def test_get_actions(hass: HomeAssistant, device_ias) -> None: ieee_address = str(device_ias[0].ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={(DOMAIN, ieee_address)} - ) - ha_entity_registry = er.async_get(hass) - siren_level_select = ha_entity_registry.async_get( + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)}) + entity_registry = er.async_get(hass) + siren_level_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_siren_level" ) - siren_tone_select = ha_entity_registry.async_get( + siren_tone_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_siren_tone" ) - strobe_level_select = ha_entity_registry.async_get( + strobe_level_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_strobe_level" ) - strobe_select = ha_entity_registry.async_get( + strobe_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_strobe" ) @@ -171,13 +169,13 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non """Test we get the expected actions from a ZHA device.""" inovelli_ieee_address = str(device_inovelli[0].ieee) - ha_device_registry = dr.async_get(hass) - inovelli_reg_device = ha_device_registry.async_get_device( + device_registry = dr.async_get(hass) + inovelli_reg_device = device_registry.async_get_device( identifiers={(DOMAIN, inovelli_ieee_address)} ) - ha_entity_registry = er.async_get(hass) - inovelli_button = ha_entity_registry.async_get("button.inovelli_vzm31_sn_identify") - inovelli_light = ha_entity_registry.async_get("light.inovelli_vzm31_sn_light") + entity_registry = er.async_get(hass) + inovelli_button = entity_registry.async_get("button.inovelli_vzm31_sn_identify") + inovelli_light = entity_registry.async_get("light.inovelli_vzm31_sn_light") actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, inovelli_reg_device.id @@ -262,11 +260,9 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: ieee_address = str(zha_device.ieee) inovelli_ieee_address = str(inovelli_zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={(DOMAIN, ieee_address)} - ) - inovelli_reg_device = ha_device_registry.async_get_device( + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)}) + inovelli_reg_device = device_registry.async_get_device( identifiers={(DOMAIN, inovelli_ieee_address)} ) diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 491e2d96d4f..096d83567fe 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -477,6 +477,7 @@ async def test_validate_trigger_config_unloaded_bad_info( # Reload ZHA to persist the device info in the cache await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() await hass.config_entries.async_unload(config_entry.entry_id) ha_device_registry = dr.async_get(hass) diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 6bcb321ab14..c13bb36c1c0 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,8 +6,8 @@ import zigpy.profiles.zha as zha import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -65,7 +65,7 @@ async def test_diagnostics_for_config_entry( """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + gateway = get_zha_gateway(hass) scan = {c: c for c in range(11, 26 + 1)} with patch.object(gateway.application_controller, "energy_scan", return_value=scan): diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index e0785601b4f..768f974d928 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -20,12 +20,12 @@ import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.device import ZHADevice import homeassistant.components.zha.core.discovery as disc from homeassistant.components.zha.core.endpoint import Endpoint +from homeassistant.components.zha.core.helpers import get_zha_gateway import homeassistant.components.zha.core.registries as zha_regs from homeassistant.const import Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from .common import get_zha_gateway from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from .zha_devices_list import ( DEV_SIG_ATTRIBUTES, diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 3d0b065ab18..81ab1c2e0f5 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -21,6 +21,7 @@ from homeassistant.components.fan import ( from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.discovery import GROUP_PROBE from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.fan import ( PRESET_MODE_AUTO, PRESET_MODE_ON, @@ -45,7 +46,6 @@ from .common import ( async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, send_attributes_report, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 0f791a08955..214bfcad9f0 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -11,11 +11,12 @@ import zigpy.zcl.clusters.lighting as lighting from homeassistant.components.zha.core.device import ZHADevice 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, get_zha_gateway +from .common import async_find_group_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index c1f5cf04e35..da91340b864 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -20,9 +20,11 @@ from homeassistant.components.zha.core.const import ( ZHA_OPTIONS, ) from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.light import FLASH_EFFECTS from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .common import ( @@ -32,7 +34,6 @@ from .common import ( async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, patch_zha_config, send_attributes_report, update_attribute_cache, @@ -1781,7 +1782,8 @@ async def test_zha_group_light_entity( assert device_3_entity_id not in zha_group.member_entity_ids # make sure the entity registry entry is still there - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + entity_registry = er.async_get(hass) + assert entity_registry.async_get(group_entity_id) is not None # add a member back and ensure that the group entity was created again await zha_group.async_add_members([GroupMember(device_light_3.ieee, 1)]) @@ -1811,10 +1813,10 @@ async def test_zha_group_light_entity( assert len(zha_group.members) == 3 # remove the group and ensure that there is no entity and that the entity registry is cleaned up - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + assert entity_registry.async_get(group_entity_id) is not None await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None + assert entity_registry.async_get(group_entity_id) is None @patch( @@ -1914,7 +1916,8 @@ async def test_group_member_assume_state( assert hass.states.get(group_entity_id).state == STATE_OFF # remove the group and ensure that there is no entity and that the entity registry is cleaned up - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + entity_registry = er.async_get(hass) + assert entity_registry.async_get(group_entity_id) is not None await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None + assert entity_registry.async_get(group_entity_id) is None diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py index beae0230901..4d11ae81b08 100644 --- a/tests/components/zha/test_silabs_multiprotocol.py +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -9,7 +9,8 @@ import zigpy.backups import zigpy.state from homeassistant.components import zha -from homeassistant.components.zha import api, silabs_multiprotocol +from homeassistant.components.zha import silabs_multiprotocol +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.core import HomeAssistant if TYPE_CHECKING: @@ -36,7 +37,7 @@ async def test_async_get_channel_missing( """Test reading channel with an inactive ZHA installation, no valid channel.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) # Network settings were never loaded for whatever reason diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index fe7450eff67..b07b34763d1 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -19,6 +19,7 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -30,7 +31,6 @@ from .common import ( async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, send_attributes_report, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 740ffd6c06c..b0e15a01318 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -940,6 +940,7 @@ async def test_websocket_bind_unbind_devices( @pytest.mark.parametrize("command_type", ["bind", "unbind"]) async def test_websocket_bind_unbind_group( command_type: str, + hass: HomeAssistant, app_controller: ControllerApplication, zha_client, ) -> None: @@ -947,8 +948,9 @@ async def test_websocket_bind_unbind_group( test_group_id = 0x0001 gateway_mock = MagicMock() + with patch( - "homeassistant.components.zha.websocket_api.get_gateway", + "homeassistant.components.zha.websocket_api.get_zha_gateway", return_value=gateway_mock, ): device_mock = MagicMock() From 0571a75c9982c25a0f48c96528ac767e921f3a1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 14:42:13 -0500 Subject: [PATCH 396/984] Bump zeroconf to 0.108.0 (#100148) --- 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 8a91b14a846..1e2205a1c1b 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.107.0"] + "requirements": ["zeroconf==0.108.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd5fdcd9dd5..c78d0343fb5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.107.0 +zeroconf==0.108.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 1ecf2917120..b073a027782 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.107.0 +zeroconf==0.108.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0180ee773ae..85bc994a928 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2045,7 +2045,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.107.0 +zeroconf==0.108.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 13cd873e388187f101fbfe12dfb86572d13436bf Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 11 Sep 2023 21:50:29 +0200 Subject: [PATCH 397/984] Fix devices not always reporting IP - bump aiounifi to v62 (#100149) --- homeassistant/components/unifi/device_tracker.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/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 2b7ac04cc0d..746e3b1fcf0 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -138,7 +138,7 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] - ip_address_fn: Callable[[aiounifi.Controller, str], str] + ip_address_fn: Callable[[aiounifi.Controller, str], str | None] is_connected_fn: Callable[[UniFiController, str], bool] hostname_fn: Callable[[aiounifi.Controller, str], str | None] @@ -247,7 +247,7 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): return self.entity_description.hostname_fn(self.controller.api, self._obj_id) @property - def ip_address(self) -> str: + def ip_address(self) -> str | None: """Return the primary ip address of the device.""" return self.entity_description.ip_address_fn(self.controller.api, self._obj_id) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f20e5f9e4ac..8734fd7dce5 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==61"], + "requirements": ["aiounifi==62"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index b073a027782..672c882b507 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -364,7 +364,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==61 +aiounifi==62 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85bc994a928..f6351dc12b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -339,7 +339,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==61 +aiounifi==62 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 5a56adb3f544c2c6e1a716db40aba100fc78a02d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Sep 2023 21:53:07 +0200 Subject: [PATCH 398/984] Refactor discovergy config flow test to use parametrize (#100115) * Refactor discovergy config flow test to use parametrize * Formatting * Implement code review sugesstions --- .../components/discovergy/test_config_flow.py | 80 ++++--------------- 1 file changed, 17 insertions(+), 63 deletions(-) diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index ad9fde46b64..9665da65789 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin +import pytest from homeassistant import data_entry_flow from homeassistant.components.discovergy.const import DOMAIN @@ -73,17 +74,27 @@ async def test_reauth( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidLogin, "invalid_auth"), + (HTTPError, "cannot_connect"), + (DiscovergyClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test to handle exceptions.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) with patch( "pydiscovergy.Discovergy.meters", - side_effect=InvalidLogin, + side_effect=error, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_EMAIL: "test@example.com", @@ -91,62 +102,5 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with patch("pydiscovergy.Discovergy.meters", side_effect=HTTPError): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_client_error(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with patch("pydiscovergy.Discovergy.meters", side_effect=DiscovergyClientError): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with patch("pydiscovergy.Discovergy.meters", side_effect=Exception): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": message} From c347c78b6d7287f0f3f1d0b3a19f10cdae530f97 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 22:25:08 +0200 Subject: [PATCH 399/984] Split Withings common file out to their own file (#100150) * Split common out in logical pieces * Split common out in logical pieces * Split common out in logical pieces --- .../withings/application_credentials.py | 51 +++++- .../components/withings/binary_sensor.py | 8 +- homeassistant/components/withings/common.py | 145 +----------------- homeassistant/components/withings/entity.py | 100 ++++++++++++ homeassistant/components/withings/sensor.py | 8 +- tests/components/withings/common.py | 5 +- tests/components/withings/test_sensor.py | 2 +- 7 files changed, 158 insertions(+), 161 deletions(-) create mode 100644 homeassistant/components/withings/entity.py diff --git a/homeassistant/components/withings/application_credentials.py b/homeassistant/components/withings/application_credentials.py index e5c401d5e74..1d5b52466c4 100644 --- a/homeassistant/components/withings/application_credentials.py +++ b/homeassistant/components/withings/application_credentials.py @@ -1,15 +1,17 @@ """application_credentials platform for Withings.""" +from typing import Any + from withings_api import AbstractWithingsApi, WithingsAuth from homeassistant.components.application_credentials import ( + AuthImplementation, AuthorizationServer, ClientCredential, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .common import WithingsLocalOAuth2Implementation from .const import DOMAIN @@ -26,3 +28,50 @@ async def async_get_auth_implementation( token_url=f"{AbstractWithingsApi.URL}/v2/oauth2", ), ) + + +class WithingsLocalOAuth2Implementation(AuthImplementation): + """Oauth2 implementation that only uses the external url.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request and adapt Withings API reply.""" + new_token = await super()._token_request(data) + # Withings API returns habitual token data under json key "body": + # { + # "status": [{integer} Withings API response status], + # "body": { + # "access_token": [{string} Your new access_token], + # "expires_in": [{integer} Access token expiry delay in seconds], + # "token_type": [{string] HTTP Authorization Header format: Bearer], + # "scope": [{string} Scopes the user accepted], + # "refresh_token": [{string} Your new refresh_token], + # "userid": [{string} The Withings ID of the user] + # } + # } + # so we copy that to token root. + if body := new_token.pop("body", None): + new_token.update(body) + return new_token + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Resolve the authorization code to tokens.""" + return await self._token_request( + { + "action": "requesttoken", + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + ) + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh tokens.""" + new_token = await self._token_request( + { + "action": "requesttoken", + "grant_type": "refresh_token", + "client_id": self.client_id, + "refresh_token": token["refresh_token"], + } + ) + return {**token, **new_token} diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index e1351d7c019..976774f23b3 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -14,13 +14,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - BaseWithingsSensor, - UpdateType, - WithingsEntityDescription, - async_get_data_manager, -) +from .common import UpdateType, async_get_data_manager from .const import Measurement +from .entity import BaseWithingsSensor, WithingsEntityDescription @dataclass diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 516c306cc0f..3d215567f45 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -28,7 +28,6 @@ from withings_api.common import ( ) from homeassistant.components import webhook -from homeassistant.components.application_credentials import AuthImplementation from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID @@ -38,13 +37,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( AbstractOAuth2Implementation, OAuth2Session, ) -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import const -from .const import DOMAIN, Measurement +from .const import Measurement _LOGGER = logging.getLogger(const.LOG_NAMESPACE) _RETRY_COEFFICIENT = 0.5 @@ -64,20 +61,6 @@ class UpdateType(StrEnum): WEBHOOK = "webhook" -@dataclass -class WithingsEntityDescriptionMixin: - """Mixin for describing withings data.""" - - measurement: Measurement - measure_type: NotifyAppli | GetSleepSummaryField | MeasureType - update_type: UpdateType - - -@dataclass -class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): - """Immutable class for describing withings data.""" - - @dataclass class WebhookConfig: """Config for a webhook.""" @@ -538,85 +521,6 @@ class DataManager: ) -def get_attribute_unique_id( - description: WithingsEntityDescription, user_id: int -) -> str: - """Get a entity unique id for a user's attribute.""" - return f"withings_{user_id}_{description.measurement.value}" - - -class BaseWithingsSensor(Entity): - """Base class for withings sensors.""" - - _attr_should_poll = False - entity_description: WithingsEntityDescription - _attr_has_entity_name = True - - def __init__( - self, data_manager: DataManager, description: WithingsEntityDescription - ) -> None: - """Initialize the Withings sensor.""" - self._data_manager = data_manager - self.entity_description = description - self._attr_unique_id = get_attribute_unique_id( - description, data_manager.user_id - ) - self._state_data: Any | None = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, - name=data_manager.profile, - ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.entity_description.update_type == UpdateType.POLL: - return self._data_manager.poll_data_update_coordinator.last_update_success - - if self.entity_description.update_type == UpdateType.WEBHOOK: - return self._data_manager.webhook_config.enabled and ( - self.entity_description.measurement - in self._data_manager.webhook_update_coordinator.data - ) - - return True - - @callback - def _on_poll_data_updated(self) -> None: - self._update_state_data( - self._data_manager.poll_data_update_coordinator.data or {} - ) - - @callback - def _on_webhook_data_updated(self) -> None: - self._update_state_data( - self._data_manager.webhook_update_coordinator.data or {} - ) - - def _update_state_data(self, data: dict[Measurement, Any]) -> None: - """Update the state data.""" - self._state_data = data.get(self.entity_description.measurement) - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register update dispatcher.""" - if self.entity_description.update_type == UpdateType.POLL: - self.async_on_remove( - self._data_manager.poll_data_update_coordinator.async_add_listener( - self._on_poll_data_updated - ) - ) - self._on_poll_data_updated() - - elif self.entity_description.update_type == UpdateType.WEBHOOK: - self.async_on_remove( - self._data_manager.webhook_update_coordinator.async_add_listener( - self._on_webhook_data_updated - ) - ) - self._on_webhook_data_updated() - - async def async_get_data_manager( hass: HomeAssistant, config_entry: ConfigEntry ) -> DataManager: @@ -680,50 +584,3 @@ def get_all_data_managers(hass: HomeAssistant) -> tuple[DataManager, ...]: def async_remove_data_manager(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Remove a data manager for a config entry.""" del hass.data[const.DOMAIN][config_entry.entry_id][const.DATA_MANAGER] - - -class WithingsLocalOAuth2Implementation(AuthImplementation): - """Oauth2 implementation that only uses the external url.""" - - async def _token_request(self, data: dict) -> dict: - """Make a token request and adapt Withings API reply.""" - new_token = await super()._token_request(data) - # Withings API returns habitual token data under json key "body": - # { - # "status": [{integer} Withings API response status], - # "body": { - # "access_token": [{string} Your new access_token], - # "expires_in": [{integer} Access token expiry delay in seconds], - # "token_type": [{string] HTTP Authorization Header format: Bearer], - # "scope": [{string} Scopes the user accepted], - # "refresh_token": [{string} Your new refresh_token], - # "userid": [{string} The Withings ID of the user] - # } - # } - # so we copy that to token root. - if body := new_token.pop("body", None): - new_token.update(body) - return new_token - - async def async_resolve_external_data(self, external_data: Any) -> dict: - """Resolve the authorization code to tokens.""" - return await self._token_request( - { - "action": "requesttoken", - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - } - ) - - async def _async_refresh_token(self, token: dict) -> dict: - """Refresh tokens.""" - new_token = await self._token_request( - { - "action": "requesttoken", - "grant_type": "refresh_token", - "client_id": self.client_id, - "refresh_token": token["refresh_token"], - } - ) - return {**token, **new_token} diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py new file mode 100644 index 00000000000..a1ad8828b81 --- /dev/null +++ b/homeassistant/components/withings/entity.py @@ -0,0 +1,100 @@ +"""Base entity for Withings.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .common import DataManager, UpdateType +from .const import DOMAIN, Measurement + + +@dataclass +class WithingsEntityDescriptionMixin: + """Mixin for describing withings data.""" + + measurement: Measurement + measure_type: NotifyAppli | GetSleepSummaryField | MeasureType + update_type: UpdateType + + +@dataclass +class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): + """Immutable class for describing withings data.""" + + +class BaseWithingsSensor(Entity): + """Base class for withings sensors.""" + + _attr_should_poll = False + entity_description: WithingsEntityDescription + _attr_has_entity_name = True + + def __init__( + self, data_manager: DataManager, description: WithingsEntityDescription + ) -> None: + """Initialize the Withings sensor.""" + self._data_manager = data_manager + self.entity_description = description + self._attr_unique_id = ( + f"withings_{data_manager.user_id}_{description.measurement.value}" + ) + self._state_data: Any | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(data_manager.user_id))}, + name=data_manager.profile, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.update_type == UpdateType.POLL: + return self._data_manager.poll_data_update_coordinator.last_update_success + + if self.entity_description.update_type == UpdateType.WEBHOOK: + return self._data_manager.webhook_config.enabled and ( + self.entity_description.measurement + in self._data_manager.webhook_update_coordinator.data + ) + + return True + + @callback + def _on_poll_data_updated(self) -> None: + self._update_state_data( + self._data_manager.poll_data_update_coordinator.data or {} + ) + + @callback + def _on_webhook_data_updated(self) -> None: + self._update_state_data( + self._data_manager.webhook_update_coordinator.data or {} + ) + + def _update_state_data(self, data: dict[Measurement, Any]) -> None: + """Update the state data.""" + self._state_data = data.get(self.entity_description.measurement) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update dispatcher.""" + if self.entity_description.update_type == UpdateType.POLL: + self.async_on_remove( + self._data_manager.poll_data_update_coordinator.async_add_listener( + self._on_poll_data_updated + ) + ) + self._on_poll_data_updated() + + elif self.entity_description.update_type == UpdateType.WEBHOOK: + self.async_on_remove( + self._data_manager.webhook_update_coordinator.async_add_listener( + self._on_webhook_data_updated + ) + ) + self._on_webhook_data_updated() diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 4f98daacc42..e8798adae2f 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -23,12 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - BaseWithingsSensor, - UpdateType, - WithingsEntityDescription, - async_get_data_manager, -) +from .common import UpdateType, async_get_data_manager from .const import ( SCORE_POINTS, UOM_BEATS_PER_MINUTE, @@ -37,6 +32,7 @@ from .const import ( UOM_MMHG, Measurement, ) +from .entity import BaseWithingsSensor, WithingsEntityDescription @dataclass diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index 6bb1b30917c..7680b19e289 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -23,11 +23,10 @@ import homeassistant.components.webhook as webhook from homeassistant.components.withings.common import ( ConfigEntryWithingsApi, DataManager, - WithingsEntityDescription, get_all_data_managers, - get_attribute_unique_id, ) import homeassistant.components.withings.const as const +from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import SOURCE_USER, ConfigEntry from homeassistant.const import ( @@ -324,6 +323,6 @@ async def async_get_entity_id( ) -> str | None: """Get an entity id for a user's attribute.""" entity_registry = er.async_get(hass) - unique_id = get_attribute_unique_id(description, user_id) + unique_id = f"withings_{user_id}_{description.measurement.value}" return entity_registry.async_get_entity_id(platform, const.DOMAIN, unique_id) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 4cc71df80d7..cf0069c968a 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -7,8 +7,8 @@ from syrupy import SnapshotAssertion from withings_api.common import NotifyAppli from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.common import WithingsEntityDescription from homeassistant.components.withings.const import Measurement +from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.components.withings.sensor import SENSORS from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er From 851dc4cdf48eab59c8179b60d1e5d114de6b00ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 12 Sep 2023 05:26:58 +0900 Subject: [PATCH 400/984] Use library for condition/wind direction conversions (#100117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/aemet/const.py | 124 ++++-------------- .../aemet/weather_update_coordinator.py | 19 +-- 2 files changed, 28 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index c6c4a9c1628..7940ff92f72 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -1,6 +1,19 @@ """Constant values for the AEMET OpenData component.""" from __future__ import annotations +from aemet_opendata.const import ( + AOD_COND_CLEAR_NIGHT, + AOD_COND_CLOUDY, + AOD_COND_FOG, + AOD_COND_LIGHTNING, + AOD_COND_LIGHTNING_RAINY, + AOD_COND_PARTLY_CLODUY, + AOD_COND_POURING, + AOD_COND_RAINY, + AOD_COND_SNOWY, + AOD_COND_SUNNY, +) + from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -55,94 +68,16 @@ ATTR_API_WIND_MAX_SPEED = "wind-max-speed" ATTR_API_WIND_SPEED = "wind-speed" CONDITIONS_MAP = { - ATTR_CONDITION_CLEAR_NIGHT: { - "11n", # Despejado (de noche) - }, - ATTR_CONDITION_CLOUDY: { - "14", # Nuboso - "14n", # Nuboso (de noche) - "15", # Muy nuboso - "15n", # Muy nuboso (de noche) - "16", # Cubierto - "16n", # Cubierto (de noche) - "17", # Nubes altas - "17n", # Nubes altas (de noche) - }, - ATTR_CONDITION_FOG: { - "81", # Niebla - "81n", # Niebla (de noche) - "82", # Bruma - Neblina - "82n", # Bruma - Neblina (de noche) - }, - ATTR_CONDITION_LIGHTNING: { - "51", # Intervalos nubosos con tormenta - "51n", # Intervalos nubosos con tormenta (de noche) - "52", # Nuboso con tormenta - "52n", # Nuboso con tormenta (de noche) - "53", # Muy nuboso con tormenta - "53n", # Muy nuboso con tormenta (de noche) - "54", # Cubierto con tormenta - "54n", # Cubierto con tormenta (de noche) - }, - ATTR_CONDITION_LIGHTNING_RAINY: { - "61", # Intervalos nubosos con tormenta y lluvia escasa - "61n", # Intervalos nubosos con tormenta y lluvia escasa (de noche) - "62", # Nuboso con tormenta y lluvia escasa - "62n", # Nuboso con tormenta y lluvia escasa (de noche) - "63", # Muy nuboso con tormenta y lluvia escasa - "63n", # Muy nuboso con tormenta y lluvia escasa (de noche) - "64", # Cubierto con tormenta y lluvia escasa - "64n", # Cubierto con tormenta y lluvia escasa (de noche) - }, - ATTR_CONDITION_PARTLYCLOUDY: { - "12", # Poco nuboso - "12n", # Poco nuboso (de noche) - "13", # Intervalos nubosos - "13n", # Intervalos nubosos (de noche) - }, - ATTR_CONDITION_POURING: { - "27", # Chubascos - "27n", # Chubascos (de noche) - }, - ATTR_CONDITION_RAINY: { - "23", # Intervalos nubosos con lluvia - "23n", # Intervalos nubosos con lluvia (de noche) - "24", # Nuboso con lluvia - "24n", # Nuboso con lluvia (de noche) - "25", # Muy nuboso con lluvia - "25n", # Muy nuboso con lluvia (de noche) - "26", # Cubierto con lluvia - "26n", # Cubierto con lluvia (de noche) - "43", # Intervalos nubosos con lluvia escasa - "43n", # Intervalos nubosos con lluvia escasa (de noche) - "44", # Nuboso con lluvia escasa - "44n", # Nuboso con lluvia escasa (de noche) - "45", # Muy nuboso con lluvia escasa - "45n", # Muy nuboso con lluvia escasa (de noche) - "46", # Cubierto con lluvia escasa - "46n", # Cubierto con lluvia escasa (de noche) - }, - ATTR_CONDITION_SNOWY: { - "33", # Intervalos nubosos con nieve - "33n", # Intervalos nubosos con nieve (de noche) - "34", # Nuboso con nieve - "34n", # Nuboso con nieve (de noche) - "35", # Muy nuboso con nieve - "35n", # Muy nuboso con nieve (de noche) - "36", # Cubierto con nieve - "36n", # Cubierto con nieve (de noche) - "71", # Intervalos nubosos con nieve escasa - "71n", # Intervalos nubosos con nieve escasa (de noche) - "72", # Nuboso con nieve escasa - "72n", # Nuboso con nieve escasa (de noche) - "73", # Muy nuboso con nieve escasa - "73n", # Muy nuboso con nieve escasa (de noche) - "74", # Cubierto con nieve escasa - "74n", # Cubierto con nieve escasa (de noche) - }, - ATTR_CONDITION_SUNNY: { - "11", # Despejado - }, + AOD_COND_CLEAR_NIGHT: ATTR_CONDITION_CLEAR_NIGHT, + AOD_COND_CLOUDY: ATTR_CONDITION_CLOUDY, + AOD_COND_FOG: ATTR_CONDITION_FOG, + AOD_COND_LIGHTNING: ATTR_CONDITION_LIGHTNING, + AOD_COND_LIGHTNING_RAINY: ATTR_CONDITION_LIGHTNING_RAINY, + AOD_COND_PARTLY_CLODUY: ATTR_CONDITION_PARTLYCLOUDY, + AOD_COND_POURING: ATTR_CONDITION_POURING, + AOD_COND_RAINY: ATTR_CONDITION_RAINY, + AOD_COND_SNOWY: ATTR_CONDITION_SNOWY, + AOD_COND_SUNNY: ATTR_CONDITION_SUNNY, } FORECAST_MONITORED_CONDITIONS = [ @@ -187,16 +122,3 @@ FORECAST_MODE_ATTR_API = { FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } - - -WIND_BEARING_MAP = { - "C": None, - "N": 0.0, - "NE": 45.0, - "E": 90.0, - "SE": 135.0, - "S": 180.0, - "SO": 225.0, - "O": 270.0, - "NO": 315.0, -} diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index c6e27374f8f..01c2502fb37 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -34,6 +34,7 @@ from aemet_opendata.const import ( ATTR_DATA, ) from aemet_opendata.exceptions import AemetError +from aemet_opendata.forecast import ForecastValue from aemet_opendata.helpers import ( get_forecast_day_value, get_forecast_hour_value, @@ -78,7 +79,6 @@ from .const import ( ATTR_API_WIND_SPEED, CONDITIONS_MAP, DOMAIN, - WIND_BEARING_MAP, ) _LOGGER = logging.getLogger(__name__) @@ -90,11 +90,8 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) def format_condition(condition: str) -> str: """Return condition from dict CONDITIONS_MAP.""" - for key, value in CONDITIONS_MAP.items(): - if condition in value: - return key - _LOGGER.error('Condition "%s" not found in CONDITIONS_MAP', condition) - return condition + val = ForecastValue.parse_condition(condition) + return CONDITIONS_MAP.get(val, val) def format_float(value) -> float | None: @@ -489,10 +486,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): val = get_forecast_hour_value( day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_DIRECTION )[0] - if val in WIND_BEARING_MAP: - return WIND_BEARING_MAP[val] - _LOGGER.error("%s not found in Wind Bearing map", val) - return None + return ForecastValue.parse_wind_direction(val) @staticmethod def _get_wind_bearing_day(day_data): @@ -500,10 +494,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): val = get_forecast_day_value( day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_DIRECTION ) - if val in WIND_BEARING_MAP: - return WIND_BEARING_MAP[val] - _LOGGER.error("%s not found in Wind Bearing map", val) - return None + return ForecastValue.parse_wind_direction(val) @staticmethod def _get_wind_max_speed(day_data, hour): From 4779cdf2aeb80678c82767abb3e9c30cdca02074 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Sep 2023 23:06:06 +0200 Subject: [PATCH 401/984] Let the discovergy config flow test end with create entry (#100153) --- .../components/discovergy/test_config_flow.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 9665da65789..08e9df06978 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.discovergy.const import GET_METERS async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: @@ -86,14 +87,24 @@ async def test_reauth( async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> None: """Test to handle exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - with patch( "pydiscovergy.Discovergy.meters", side_effect=error, ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} + + with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS): result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -102,5 +113,6 @@ async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> }, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": message} + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert "errors" not in result From e0e05f95463b94025dbfde635ec45cb4b666b54e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 11 Sep 2023 23:06:21 +0200 Subject: [PATCH 402/984] Update frontend to 20230911.0 (#100139) --- 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 58de25fc03d..6291e3a237e 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==20230908.0"] + "requirements": ["home-assistant-frontend==20230911.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c78d0343fb5..61d2b5d35a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230908.0 +home-assistant-frontend==20230911.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 672c882b507..d7b6461ff42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -998,7 +998,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230908.0 +home-assistant-frontend==20230911.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6351dc12b3..c57e5a67fad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -784,7 +784,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230908.0 +home-assistant-frontend==20230911.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From e231da42e1076662505432250967d775988f2d46 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 11 Sep 2023 22:21:44 -0400 Subject: [PATCH 403/984] Handle disconnects in zwave_js repair flow (#99964) * Handle disconnects in zwave_js repair flow * Combine logic to reduce LoC * only check once --- homeassistant/components/zwave_js/repairs.py | 24 +++--- .../components/zwave_js/strings.json | 3 + tests/components/zwave_js/test_repairs.py | 74 ++++++++++++++++--- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index 89f51dddb88..83ee0523a3b 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -2,7 +2,6 @@ from __future__ import annotations import voluptuous as vol -from zwave_js_server.model.node import Node from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow @@ -14,10 +13,10 @@ from .helpers import async_get_node_from_device_id class DeviceConfigFileChangedFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, node: Node, device_name: str) -> None: + def __init__(self, data: dict[str, str]) -> None: """Initialize.""" - self.node = node - self.device_name = device_name + self.device_name: str = data["device_name"] + self.device_id: str = data["device_id"] async def async_step_init( self, user_input: dict[str, str] | None = None @@ -30,7 +29,14 @@ class DeviceConfigFileChangedFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - self.hass.async_create_task(self.node.async_refresh_info()) + try: + node = async_get_node_from_device_id(self.hass, self.device_id) + except ValueError: + return self.async_abort( + reason="cannot_connect", + description_placeholders={"device_name": self.device_name}, + ) + self.hass.async_create_task(node.async_refresh_info()) return self.async_create_entry(title="", data={}) return self.async_show_form( @@ -41,15 +47,11 @@ class DeviceConfigFileChangedFlow(RepairsFlow): async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str] | None, + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: """Create flow.""" if issue_id.split(".")[0] == "device_config_file_changed": assert data - return DeviceConfigFileChangedFlow( - async_get_node_from_device_id(hass, data["device_id"]), data["device_name"] - ) + return DeviceConfigFileChangedFlow(data) return ConfirmRepairFlow() diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 6435c6b7a54..6994ce15a0c 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -170,6 +170,9 @@ "title": "Z-Wave device configuration file changed: {device_name}", "description": "Z-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you'd like to proceed, click on SUBMIT below. The re-interview will take place in the background." } + }, + "abort": { + "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant." } } } diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index 07371a299ef..d18bcfa09aa 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -22,16 +22,10 @@ import homeassistant.helpers.issue_registry as ir from tests.typing import ClientSessionGenerator, WebSocketGenerator -async def test_device_config_file_changed( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, - client, - multisensor_6_state, - integration, -) -> None: - """Test the device_config_file_changed issue.""" - dev_reg = dr.async_get(hass) +async def _trigger_repair_issue( + hass: HomeAssistant, client, multisensor_6_state +) -> Node: + """Trigger repair issue.""" # Create a node node_state = deepcopy(multisensor_6_state) node = Node(client, node_state) @@ -53,6 +47,23 @@ async def test_device_config_file_changed( client.async_send_command_no_wait.reset_mock() + return node + + +async def test_device_config_file_changed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test the device_config_file_changed issue.""" + dev_reg = dr.async_get(hass) + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + client.async_send_command_no_wait.reset_mock() + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) assert device issue_id = f"device_config_file_changed.{device.id}" @@ -157,3 +168,46 @@ async def test_invalid_issue( msg = await ws_client.receive_json() assert msg["success"] assert len(msg["result"]["issues"]) == 0 + + +async def test_abort_confirm( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test aborting device_config_file_changed issue in confirm step.""" + dev_reg = dr.async_get(hass) + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + assert device + issue_id = f"device_config_file_changed.{device.id}" + + await async_process_repairs_platforms(hass) + await hass_ws_client(hass) + http_client = await hass_client() + + url = RepairsFlowIndexView.url + resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Unload config entry so we can't connect to the node + await hass.config_entries.async_unload(integration.entry_id) + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "abort" + assert data["reason"] == "cannot_connect" + assert data["description_placeholders"] == {"device_name": device.name} From 15b9963a24bdd08efcb3afc3785426762cd6f441 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 12 Sep 2023 04:23:55 +0200 Subject: [PATCH 404/984] Bump ZHA dependencies (#100156) --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index cce223fac11..c3fa6b1ff01 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -25,9 +25,9 @@ "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.103", - "zigpy-deconz==0.21.0", + "zigpy-deconz==0.21.1", "zigpy==0.57.1", - "zigpy-xbee==0.18.1", + "zigpy-xbee==0.18.2", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.4", "universal-silabs-flasher==0.0.13" diff --git a/requirements_all.txt b/requirements_all.txt index d7b6461ff42..d998da2b537 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2784,10 +2784,10 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.21.0 +zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.1 +zigpy-xbee==0.18.2 # homeassistant.components.zha zigpy-zigate==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c57e5a67fad..ce2d15a5632 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2054,10 +2054,10 @@ zeversolar==0.3.1 zha-quirks==0.0.103 # homeassistant.components.zha -zigpy-deconz==0.21.0 +zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.1 +zigpy-xbee==0.18.2 # homeassistant.components.zha zigpy-zigate==0.11.0 From 3d28c6d6369e91d1ea1abd3ff16f2bb4c356b476 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 12 Sep 2023 04:30:50 +0200 Subject: [PATCH 405/984] Fix AVM Fritz!Tools update entity (#100151) * move update entity to coordinator * fix tests --- homeassistant/components/fritz/common.py | 11 ++++-- homeassistant/components/fritz/update.py | 38 ++++++++++++------ tests/components/fritz/test_update.py | 49 +++++++++++++++--------- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 69773778121..76368175ca0 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1096,7 +1096,7 @@ class FritzBoxBaseEntity: class FritzRequireKeysMixin: """Fritz entity description mix in.""" - value_fn: Callable[[FritzStatus, Any], Any] + value_fn: Callable[[FritzStatus, Any], Any] | None @dataclass @@ -1118,9 +1118,12 @@ class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity[AvmWrap ) -> None: """Init device info class.""" super().__init__(avm_wrapper) - self.async_on_remove( - avm_wrapper.register_entity_updates(description.key, description.value_fn) - ) + if description.value_fn is not None: + self.async_on_remove( + avm_wrapper.register_entity_updates( + description.key, description.value_fn + ) + ) self.entity_description = description self._device_name = device_name self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 03cffc3cae6..80cbe1f4c5c 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -1,20 +1,31 @@ """Support for AVM FRITZ!Box update platform.""" from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any -from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, FritzBoxBaseEntity +from .common import AvmWrapper, FritzBoxBaseCoordinatorEntity, FritzEntityDescription from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +@dataclass +class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): + """Describes Fritz update entity.""" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -27,11 +38,13 @@ async def async_setup_entry( async_add_entities(entities) -class FritzBoxUpdateEntity(FritzBoxBaseEntity, UpdateEntity): +class FritzBoxUpdateEntity(FritzBoxBaseCoordinatorEntity, UpdateEntity): """Mixin for update entity specific attributes.""" + _attr_entity_category = EntityCategory.CONFIG _attr_supported_features = UpdateEntityFeature.INSTALL _attr_title = "FRITZ!OS" + entity_description: FritzUpdateEntityDescription def __init__( self, @@ -39,29 +52,30 @@ class FritzBoxUpdateEntity(FritzBoxBaseEntity, UpdateEntity): device_friendly_name: str, ) -> None: """Init FRITZ!Box connectivity class.""" - self._attr_name = f"{device_friendly_name} FRITZ!OS" - self._attr_unique_id = f"{avm_wrapper.unique_id}-update" - super().__init__(avm_wrapper, device_friendly_name) + description = FritzUpdateEntityDescription( + key="update", name="FRITZ!OS", value_fn=None + ) + super().__init__(avm_wrapper, device_friendly_name, description) @property def installed_version(self) -> str | None: """Version currently in use.""" - return self._avm_wrapper.current_firmware + return self.coordinator.current_firmware @property def latest_version(self) -> str | None: """Latest version available for install.""" - if self._avm_wrapper.update_available: - return self._avm_wrapper.latest_firmware - return self._avm_wrapper.current_firmware + if self.coordinator.update_available: + return self.coordinator.latest_firmware + return self.coordinator.current_firmware @property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" - return self._avm_wrapper.release_url + return self.coordinator.release_url async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - await self._avm_wrapper.async_trigger_firmware_update() + await self.coordinator.async_trigger_firmware_update() diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index dbff4713553..bc677e28ebe 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -8,11 +8,25 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL, MOCK_USER_DATA +from .const import ( + MOCK_FB_SERVICES, + MOCK_FIRMWARE_AVAILABLE, + MOCK_FIRMWARE_RELEASE_URL, + MOCK_USER_DATA, +) from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator +AVAILABLE_UPDATE = { + "UserInterface1": { + "GetInfo": { + "NewX_AVM-DE_Version": MOCK_FIRMWARE_AVAILABLE, + "NewX_AVM-DE_InfoURL": MOCK_FIRMWARE_RELEASE_URL, + }, + } +} + async def test_update_entities_initialized( hass: HomeAssistant, @@ -41,23 +55,21 @@ async def test_update_available( ) -> None: """Test update entities.""" - with patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", - return_value=(True, MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL), - ): - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + fc_class_mock().override_services({**MOCK_FB_SERVICES, **AVAILABLE_UPDATE}) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert entry.state == ConfigEntryState.LOADED + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) - update = hass.states.get("update.mock_title_fritz_os") - assert update is not None - assert update.state == "on" - assert update.attributes.get("installed_version") == "7.29" - assert update.attributes.get("latest_version") == MOCK_FIRMWARE_AVAILABLE - assert update.attributes.get("release_url") == MOCK_FIRMWARE_RELEASE_URL + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + update = hass.states.get("update.mock_title_fritz_os") + assert update is not None + assert update.state == "on" + assert update.attributes.get("installed_version") == "7.29" + assert update.attributes.get("latest_version") == MOCK_FIRMWARE_AVAILABLE + assert update.attributes.get("release_url") == MOCK_FIRMWARE_RELEASE_URL async def test_no_update_available( @@ -90,10 +102,9 @@ async def test_available_update_can_be_installed( ) -> None: """Test update entities.""" + fc_class_mock().override_services({**MOCK_FB_SERVICES, **AVAILABLE_UPDATE}) + with patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", - return_value=(True, MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL), - ), patch( "homeassistant.components.fritz.common.FritzBoxTools.async_trigger_firmware_update", return_value=True, ) as mocked_update_call: From a20d1a357fbe546102f6876b990b6ff03fa7b17c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 21:34:23 -0500 Subject: [PATCH 406/984] Avoid probing ipp printers for unique_id when it is available via mdns (#99982) * Avoid probing ipp printers for unique_id when it is available via mdns We would always probe the device in the ipp flow and than abort if it was already configured. We avoid the probe for most printers. * dry * coverage * fix test * add test for updating host --- homeassistant/components/ipp/config_flow.py | 36 ++++-- .../ipp/fixtures/printer_without_uuid.json | 35 ++++++ tests/components/ipp/test_config_flow.py | 103 +++++++++++++++++- 3 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 tests/components/ipp/fixtures/printer_without_uuid.json diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index 8d1da6eca91..dfe6c0b2127 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -116,8 +116,7 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): name = discovery_info.name.replace(f".{zctype}", "") tls = zctype == "_ipps._tcp.local." base_path = discovery_info.properties.get("rp", "ipp/print") - - self.context.update({"title_placeholders": {"name": name}}) + unique_id = discovery_info.properties.get("UUID") self.discovery_info.update( { @@ -127,10 +126,18 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): CONF_VERIFY_SSL: False, CONF_BASE_PATH: f"/{base_path}", CONF_NAME: name, - CONF_UUID: discovery_info.properties.get("UUID"), + CONF_UUID: unique_id, } ) + if unique_id: + # If we already have the unique id, try to set it now + # so we can avoid probing the device if its already + # configured or ignored + await self._async_set_unique_id_and_abort_if_already_configured(unique_id) + + self.context.update({"title_placeholders": {"name": name}}) + try: info = await validate_input(self.hass, self.discovery_info) except IPPConnectionUpgradeRequired: @@ -147,7 +154,6 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug("IPP Error", exc_info=True) return self.async_abort(reason="ipp_error") - unique_id = self.discovery_info[CONF_UUID] if not unique_id and info[CONF_UUID]: _LOGGER.debug( "Printer UUID is missing from discovery info. Falling back to IPP UUID" @@ -164,18 +170,24 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): "Unable to determine unique id from discovery info and IPP response" ) - if unique_id: - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.discovery_info[CONF_HOST], - CONF_NAME: self.discovery_info[CONF_NAME], - }, - ) + if unique_id and self.unique_id != unique_id: + await self._async_set_unique_id_and_abort_if_already_configured(unique_id) await self._async_handle_discovery_without_unique_id() return await self.async_step_zeroconf_confirm() + async def _async_set_unique_id_and_abort_if_already_configured( + self, unique_id: str + ) -> None: + """Set the unique ID and abort if already configured.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.discovery_info[CONF_HOST], + CONF_NAME: self.discovery_info[CONF_NAME], + }, + ) + async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/tests/components/ipp/fixtures/printer_without_uuid.json b/tests/components/ipp/fixtures/printer_without_uuid.json new file mode 100644 index 00000000000..21f1eb93a32 --- /dev/null +++ b/tests/components/ipp/fixtures/printer_without_uuid.json @@ -0,0 +1,35 @@ +{ + "printer-state": "idle", + "printer-name": "Test Printer", + "printer-location": null, + "printer-make-and-model": "Test HA-1000 Series", + "printer-device-id": "MFG:TEST;CMD:ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF;MDL:HA-1000 Series;CLS:PRINTER;DES:TEST HA-1000 Series;CID:EpsonRGB;FID:FXN,DPA,WFA,ETN,AFN,DAN,WRA;RID:20;DDS:022500;ELG:1000;SN:555534593035345555;URF:CP1,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7-6,V1.4,MT1-3-7-8-10-11-12;", + "printer-uri-supported": [ + "ipps://192.168.1.31:631/ipp/print", + "ipp://192.168.1.31:631/ipp/print" + ], + "uri-authentication-supported": ["none", "none"], + "uri-security-supported": ["tls", "none"], + "printer-info": "Test HA-1000 Series", + "printer-up-time": 30, + "printer-firmware-string-version": "20.23.06HA", + "printer-more-info": "http://192.168.1.31:80/PRESENTATION/BONJOUR", + "marker-names": [ + "Black ink", + "Photo black ink", + "Cyan ink", + "Yellow ink", + "Magenta ink" + ], + "marker-types": [ + "ink-cartridge", + "ink-cartridge", + "ink-cartridge", + "ink-cartridge", + "ink-cartridge" + ], + "marker-colors": ["#000000", "#000000", "#00FFFF", "#FFFF00", "#FF00FF"], + "marker-levels": [58, 98, 91, 95, 73], + "marker-low-levels": [10, 10, 10, 10, 10], + "marker-high-levels": [100, 100, 100, 100, 100] +} diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 69a2bb9287a..0daf8a0f7e0 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for the IPP config flow.""" import dataclasses -from unittest.mock import MagicMock +import json +from unittest.mock import MagicMock, patch from pyipp import ( IPPConnectionError, @@ -8,6 +9,7 @@ from pyipp import ( IPPError, IPPParseError, IPPVersionNotSupportedError, + Printer, ) import pytest @@ -23,7 +25,7 @@ from . import ( MOCK_ZEROCONF_IPPS_SERVICE_INFO, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -316,6 +318,31 @@ async def test_zeroconf_with_uuid_device_exists_abort( assert result["reason"] == "already_configured" +async def test_zeroconf_with_uuid_device_exists_abort_new_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ipp_config_flow: MagicMock, +) -> None: + """Test we abort zeroconf flow if printer already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO, host="1.2.3.9") + discovery_info.properties = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, + "UUID": "cfe92100-67c4-11d4-a45f-f8d027761251", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "1.2.3.9" + + async def test_zeroconf_empty_unique_id( hass: HomeAssistant, mock_ipp_config_flow: MagicMock, @@ -337,6 +364,21 @@ async def test_zeroconf_empty_unique_id( assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + + assert result["result"] + assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + async def test_zeroconf_no_unique_id( hass: HomeAssistant, @@ -355,6 +397,21 @@ async def test_zeroconf_no_unique_id( assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + + assert result["result"] + assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + async def test_full_user_flow_implementation( hass: HomeAssistant, @@ -448,3 +505,45 @@ async def test_full_zeroconf_tls_flow_implementation( assert result["result"] assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + + +async def test_zeroconf_empty_unique_id_uses_serial(hass: HomeAssistant) -> None: + """Test zeroconf flow if printer lacks (empty) unique identification with serial fallback.""" + fixture = await hass.async_add_executor_job( + load_fixture, "ipp/printer_without_uuid.json" + ) + mock_printer_without_uuid = Printer.from_dict(json.loads(fixture)) + mock_printer_without_uuid.unique_id = None + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) + discovery_info.properties = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, + "UUID": "", + } + with patch( + "homeassistant.components.ipp.config_flow.IPP", autospec=True + ) as ipp_mock: + client = ipp_mock.return_value + client.printer.return_value = mock_printer_without_uuid + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "" + + assert result["result"] + assert result["result"].unique_id == "555534593035345555" From 140af44e315bc0ea7ce8e36db90a90c218899d69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 21:40:32 -0500 Subject: [PATCH 407/984] Bump dbus-fast to 2.4.0 (#100158) --- 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 8cc2a7adb65..762117052f0 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", - "dbus-fast==2.2.0" + "dbus-fast==2.4.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 61d2b5d35a6..fa9fade4031 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.2.0 +dbus-fast==2.4.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index d998da2b537..0b73a9d4bc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.2.0 +dbus-fast==2.4.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce2d15a5632..851582ee701 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.2.0 +dbus-fast==2.4.0 # homeassistant.components.debugpy debugpy==1.6.7 From 5d46e225918a225424b44532518205bc33266068 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 12 Sep 2023 04:52:02 +0200 Subject: [PATCH 408/984] Move airly coordinator to its own file (#99545) --- homeassistant/components/airly/__init__.py | 121 +---------------- homeassistant/components/airly/coordinator.py | 126 ++++++++++++++++++ tests/components/airly/test_init.py | 2 +- 3 files changed, 129 insertions(+), 120 deletions(-) create mode 100644 homeassistant/components/airly/coordinator.py diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 982687c7723..91208de519b 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -1,15 +1,8 @@ """The Airly integration.""" from __future__ import annotations -from asyncio import timeout from datetime import timedelta import logging -from math import ceil - -from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientConnectorError -from airly import Airly -from airly.exceptions import AirlyError from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -17,53 +10,15 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Pla from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util -from .const import ( - ATTR_API_ADVICE, - ATTR_API_CAQI, - ATTR_API_CAQI_DESCRIPTION, - ATTR_API_CAQI_LEVEL, - CONF_USE_NEAREST, - DOMAIN, - MAX_UPDATE_INTERVAL, - MIN_UPDATE_INTERVAL, - NO_AIRLY_SENSORS, -) +from .const import CONF_USE_NEAREST, DOMAIN, MIN_UPDATE_INTERVAL +from .coordinator import AirlyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -def set_update_interval(instances_count: int, requests_remaining: int) -> timedelta: - """Return data update interval. - - The number of requests is reset at midnight UTC so we calculate the update - interval based on number of minutes until midnight, the number of Airly instances - and the number of remaining requests. - """ - now = dt_util.utcnow() - midnight = dt_util.find_next_time_expression_time( - now, seconds=[0], minutes=[0], hours=[0] - ) - minutes_to_midnight = (midnight - now).total_seconds() / 60 - interval = timedelta( - minutes=min( - max( - ceil(minutes_to_midnight / requests_remaining * instances_count), - MIN_UPDATE_INTERVAL, - ), - MAX_UPDATE_INTERVAL, - ) - ) - - _LOGGER.debug("Data will be update every %s", interval) - - return interval - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Airly as config entry.""" api_key = entry.data[CONF_API_KEY] @@ -131,75 +86,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AirlyDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to hold Airly data.""" - - def __init__( - self, - hass: HomeAssistant, - session: ClientSession, - api_key: str, - latitude: float, - longitude: float, - update_interval: timedelta, - use_nearest: bool, - ) -> None: - """Initialize.""" - self.latitude = latitude - self.longitude = longitude - # Currently, Airly only supports Polish and English - language = "pl" if hass.config.language == "pl" else "en" - self.airly = Airly(api_key, session, language=language) - self.use_nearest = use_nearest - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self) -> dict[str, str | float | int]: - """Update data via library.""" - data: dict[str, str | float | int] = {} - if self.use_nearest: - measurements = self.airly.create_measurements_session_nearest( - self.latitude, self.longitude, max_distance_km=5 - ) - else: - measurements = self.airly.create_measurements_session_point( - self.latitude, self.longitude - ) - async with timeout(20): - try: - await measurements.update() - except (AirlyError, ClientConnectorError) as error: - raise UpdateFailed(error) from error - - _LOGGER.debug( - "Requests remaining: %s/%s", - self.airly.requests_remaining, - self.airly.requests_per_day, - ) - - # Airly API sometimes returns None for requests remaining so we update - # update_interval only if we have valid value. - if self.airly.requests_remaining: - self.update_interval = set_update_interval( - len(self.hass.config_entries.async_entries(DOMAIN)), - self.airly.requests_remaining, - ) - - values = measurements.current["values"] - index = measurements.current["indexes"][0] - standards = measurements.current["standards"] - - if index["description"] == NO_AIRLY_SENSORS: - raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") - for value in values: - data[value["name"]] = value["value"] - for standard in standards: - data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] - data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] - data[ATTR_API_CAQI] = index["value"] - data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") - data[ATTR_API_CAQI_DESCRIPTION] = index["description"] - data[ATTR_API_ADVICE] = index["advice"] - return data diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py new file mode 100644 index 00000000000..9f2a1c96511 --- /dev/null +++ b/homeassistant/components/airly/coordinator.py @@ -0,0 +1,126 @@ +"""DataUpdateCoordinator for the Airly integration.""" +from asyncio import timeout +from datetime import timedelta +import logging +from math import ceil + +from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientConnectorError +from airly import Airly +from airly.exceptions import AirlyError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_API_ADVICE, + ATTR_API_CAQI, + ATTR_API_CAQI_DESCRIPTION, + ATTR_API_CAQI_LEVEL, + DOMAIN, + MAX_UPDATE_INTERVAL, + MIN_UPDATE_INTERVAL, + NO_AIRLY_SENSORS, +) + +_LOGGER = logging.getLogger(__name__) + + +def set_update_interval(instances_count: int, requests_remaining: int) -> timedelta: + """Return data update interval. + + The number of requests is reset at midnight UTC so we calculate the update + interval based on number of minutes until midnight, the number of Airly instances + and the number of remaining requests. + """ + now = dt_util.utcnow() + midnight = dt_util.find_next_time_expression_time( + now, seconds=[0], minutes=[0], hours=[0] + ) + minutes_to_midnight = (midnight - now).total_seconds() / 60 + interval = timedelta( + minutes=min( + max( + ceil(minutes_to_midnight / requests_remaining * instances_count), + MIN_UPDATE_INTERVAL, + ), + MAX_UPDATE_INTERVAL, + ) + ) + + _LOGGER.debug("Data will be update every %s", interval) + + return interval + + +class AirlyDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold Airly data.""" + + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + api_key: str, + latitude: float, + longitude: float, + update_interval: timedelta, + use_nearest: bool, + ) -> None: + """Initialize.""" + self.latitude = latitude + self.longitude = longitude + # Currently, Airly only supports Polish and English + language = "pl" if hass.config.language == "pl" else "en" + self.airly = Airly(api_key, session, language=language) + self.use_nearest = use_nearest + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self) -> dict[str, str | float | int]: + """Update data via library.""" + data: dict[str, str | float | int] = {} + if self.use_nearest: + measurements = self.airly.create_measurements_session_nearest( + self.latitude, self.longitude, max_distance_km=5 + ) + else: + measurements = self.airly.create_measurements_session_point( + self.latitude, self.longitude + ) + async with timeout(20): + try: + await measurements.update() + except (AirlyError, ClientConnectorError) as error: + raise UpdateFailed(error) from error + + _LOGGER.debug( + "Requests remaining: %s/%s", + self.airly.requests_remaining, + self.airly.requests_per_day, + ) + + # Airly API sometimes returns None for requests remaining so we update + # update_interval only if we have valid value. + if self.airly.requests_remaining: + self.update_interval = set_update_interval( + len(self.hass.config_entries.async_entries(DOMAIN)), + self.airly.requests_remaining, + ) + + values = measurements.current["values"] + index = measurements.current["indexes"][0] + standards = measurements.current["standards"] + + if index["description"] == NO_AIRLY_SENSORS: + raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") + for value in values: + data[value["name"]] = value["value"] + for standard in standards: + data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] + data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] + data[ATTR_API_CAQI] = index["value"] + data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") + data[ATTR_API_CAQI_DESCRIPTION] = index["description"] + data[ATTR_API_ADVICE] = index["advice"] + return data diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 9b69607e6aa..0a3ea927446 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -5,8 +5,8 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM -from homeassistant.components.airly import set_update_interval from homeassistant.components.airly.const import DOMAIN +from homeassistant.components.airly.coordinator import set_update_interval from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant From 8e43f79f19538de4d2016396adcc3087664043bf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 11 Sep 2023 23:03:47 -0400 Subject: [PATCH 409/984] Bump zwave-js-server-python to 0.51.2 (#100159) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 080074451bd..4ea46099f14 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.51.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.2"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 0b73a9d4bc7..eaeab3d151a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2802,7 +2802,7 @@ zigpy==0.57.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.1 +zwave-js-server-python==0.51.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 851582ee701..d81c6ecb22b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2069,7 +2069,7 @@ zigpy-znp==0.11.4 zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.1 +zwave-js-server-python==0.51.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 183b77973f5f1e0f84a7f94942594a2bebfde90e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 11 Sep 2023 22:56:08 -0700 Subject: [PATCH 410/984] Add configuration flow to Todoist integration (#100094) * Add config flow to todoist * Fix service calls for todoist * Fix configuration entry test setup * Bump test coverage to 100% * Apply pr feedback --- homeassistant/components/todoist/__init__.py | 45 +++++- homeassistant/components/todoist/calendar.py | 48 ++++++- .../components/todoist/config_flow.py | 63 ++++++++ .../components/todoist/coordinator.py | 18 ++- .../components/todoist/manifest.json | 1 + homeassistant/components/todoist/strings.json | 19 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/todoist/conftest.py | 135 ++++++++++++++++++ tests/components/todoist/test_calendar.py | 115 ++++++--------- tests/components/todoist/test_config_flow.py | 123 ++++++++++++++++ tests/components/todoist/test_init.py | 47 ++++++ 12 files changed, 540 insertions(+), 77 deletions(-) create mode 100644 homeassistant/components/todoist/config_flow.py create mode 100644 tests/components/todoist/conftest.py create mode 100644 tests/components/todoist/test_config_flow.py create mode 100644 tests/components/todoist/test_init.py diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 78a9cb89624..12b75a40bae 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -1 +1,44 @@ -"""The todoist component.""" +"""The todoist integration.""" + +import datetime +import logging + +from todoist_api_python.api_async import TodoistAPIAsync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TodoistCoordinator + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(minutes=1) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up todoist from a config entry.""" + + token = entry.data[CONF_TOKEN] + api = TodoistAPIAsync(token) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + 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/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 544144018dd..40ceb71ee5f 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -17,8 +17,10 @@ from homeassistant.components.calendar import ( CalendarEntity, CalendarEvent, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -106,6 +108,23 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( SCAN_INTERVAL = timedelta(minutes=1) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Todoist calendar platform config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + projects = await coordinator.async_get_projects() + labels = await coordinator.async_get_labels() + + entities = [] + for project in projects: + project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} + entities.append(TodoistProjectEntity(coordinator, project_data, labels)) + + async_add_entities(entities) + async_register_services(hass, coordinator) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -119,7 +138,7 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) - coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) await coordinator.async_refresh() async def _shutdown_coordinator(_: Event) -> None: @@ -177,12 +196,29 @@ async def async_setup_platform( async_add_entities(project_devices, update_before_add=True) + async_register_services(hass, coordinator) + + +def async_register_services( + hass: HomeAssistant, coordinator: TodoistCoordinator +) -> None: + """Register services.""" + + if hass.services.has_service(DOMAIN, SERVICE_NEW_TASK): + return + session = async_get_clientsession(hass) async def handle_new_task(call: ServiceCall) -> None: """Call when a user creates a new Todoist Task from Home Assistant.""" - project_name = call.data[PROJECT_NAME] - project_id = project_id_lookup[project_name] + project_name = call.data[PROJECT_NAME].lower() + projects = await coordinator.async_get_projects() + project_id: str | None = None + for project in projects: + if project_name == project.name.lower(): + project_id = project.id + if project_id is None: + raise HomeAssistantError(f"Invalid project name '{project_name}'") # Create the task content = call.data[CONTENT] @@ -192,7 +228,7 @@ async def async_setup_platform( data["labels"] = task_labels if ASSIGNEE in call.data: - collaborators = await api.get_collaborators(project_id) + collaborators = await coordinator.api.get_collaborators(project_id) collaborator_id_lookup = { collab.name.lower(): collab.id for collab in collaborators } @@ -225,7 +261,7 @@ async def async_setup_platform( date_format = "%Y-%m-%dT%H:%M:%S" data["due_datetime"] = datetime.strftime(due_date, date_format) - api_task = await api.add_task(content, **data) + api_task = await coordinator.api.add_task(content, **data) # @NOTE: The rest-api doesn't support reminders, this works manually using # the sync api, in order to keep functional parity with the component. @@ -263,7 +299,7 @@ async def async_setup_platform( } ] } - headers = create_headers(token=token, with_content=True) + headers = create_headers(token=coordinator.token, with_content=True) return await session.post(sync_url, headers=headers, json=reminder_data) if _reminder_due: diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py new file mode 100644 index 00000000000..0a41ecb0463 --- /dev/null +++ b/homeassistant/components/todoist/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for todoist integration.""" + +from http import HTTPStatus +import logging +from typing import Any + +from requests.exceptions import HTTPError +from todoist_api_python.api_async import TodoistAPIAsync +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SETTINGS_URL = "https://todoist.com/app/settings/integrations" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for todoist.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors: dict[str, str] = {} + if user_input is not None: + api = TodoistAPIAsync(user_input[CONF_TOKEN]) + try: + await api.get_tasks() + except HTTPError as err: + if err.response.status_code == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_access_token" + else: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_TOKEN]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Todoist", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"settings_url": SETTINGS_URL}, + ) diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index b573d1d1127..702c43883ea 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging from todoist_api_python.api_async import TodoistAPIAsync -from todoist_api_python.models import Task +from todoist_api_python.models import Label, Project, Task from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,10 +18,14 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): logger: logging.Logger, update_interval: timedelta, api: TodoistAPIAsync, + token: str, ) -> None: """Initialize the Todoist coordinator.""" super().__init__(hass, logger, name="Todoist", update_interval=update_interval) self.api = api + self._projects: list[Project] | None = None + self._labels: list[Label] | None = None + self.token = token async def _async_update_data(self) -> list[Task]: """Fetch tasks from the Todoist API.""" @@ -29,3 +33,15 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): return await self.api.get_tasks() except Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def async_get_projects(self) -> list[Project]: + """Return todoist projects fetched at most once.""" + if self._projects is None: + self._projects = await self.api.get_projects() + return self._projects + + async def async_get_labels(self) -> list[Label]: + """Return todoist labels fetched at most once.""" + if self._labels is None: + self._labels = await self.api.get_labels() + return self._labels diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index a83cdbe1b09..72d76108353 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -2,6 +2,7 @@ "domain": "todoist", "name": "Todoist", "codeowners": ["@boralyl"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 1ed092e5cf6..123b5d07ed7 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -1,4 +1,23 @@ { + "config": { + "step": { + "user": { + "data": { + "token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "Please entry your API token from your [Todoist Settings page]({settings_url})" + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, "services": { "new_task": { "name": "New task", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1557df8f33b..98935086b88 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -473,6 +473,7 @@ FLOWS = { "tibber", "tile", "tilt_ble", + "todoist", "tolo", "tomorrowio", "toon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7cad78a49fc..779ee92e9fe 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5808,7 +5808,7 @@ "todoist": { "name": "Todoist", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "tolo": { diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py new file mode 100644 index 00000000000..6543e5b678f --- /dev/null +++ b/tests/components/todoist/conftest.py @@ -0,0 +1,135 @@ +"""Common fixtures for the todoist tests.""" +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest +from requests.exceptions import HTTPError +from requests.models import Response +from todoist_api_python.models import Collaborator, Due, Label, Project, Task + +from homeassistant.components.todoist import DOMAIN +from homeassistant.const import CONF_TOKEN +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 + +SUMMARY = "A task" +TOKEN = "some-token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.todoist.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="due") +def mock_due() -> Due: + """Mock a todoist Task Due date/time.""" + return Due( + is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" + ) + + +@pytest.fixture(name="task") +def mock_task(due: Due) -> Task: + """Mock a todoist Task instance.""" + return Task( + assignee_id="1", + assigner_id="1", + comment_count=0, + is_completed=False, + content=SUMMARY, + created_at="2021-10-01T00:00:00", + creator_id="1", + description="A task", + due=due, + id="1", + labels=["Label1"], + order=1, + parent_id=None, + priority=1, + project_id="12345", + section_id=None, + url="https://todoist.com", + sync_id=None, + ) + + +@pytest.fixture(name="api") +def mock_api(task) -> AsyncMock: + """Mock the api state.""" + api = AsyncMock() + api.get_projects.return_value = [ + Project( + id="12345", + color="blue", + comment_count=0, + is_favorite=False, + name="Name", + is_shared=False, + url="", + is_inbox_project=False, + is_team_inbox=False, + order=1, + parent_id=None, + view_style="list", + ) + ] + api.get_labels.return_value = [ + Label(id="1", name="Label1", color="1", order=1, is_favorite=False) + ] + api.get_collaborators.return_value = [ + Collaborator(email="user@gmail.com", id="1", name="user") + ] + api.get_tasks.return_value = [task] + return api + + +@pytest.fixture(name="todoist_api_status") +def mock_api_status() -> HTTPStatus | None: + """Fixture to inject an http status error.""" + return None + + +@pytest.fixture(autouse=True) +def mock_api_side_effect( + api: AsyncMock, todoist_api_status: HTTPStatus | None +) -> MockConfigEntry: + """Mock todoist configuration.""" + if todoist_api_status: + response = Response() + response.status_code = todoist_api_status + api.get_tasks.side_effect = HTTPError(response=response) + + +@pytest.fixture(name="todoist_config_entry") +def mock_todoist_config_entry() -> MockConfigEntry: + """Mock todoist configuration.""" + return MockConfigEntry(domain=DOMAIN, unique_id=TOKEN, data={CONF_TOKEN: TOKEN}) + + +@pytest.fixture(name="todoist_domain") +def mock_todoist_domain() -> str: + """Mock todoist configuration.""" + return DOMAIN + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + api: AsyncMock, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Mock setup of the todoist integration.""" + if todoist_config_entry is not None: + todoist_config_entry.add_to_hass(hass) + with patch("homeassistant.components.todoist.TodoistAPIAsync", return_value=api): + assert await async_setup_component(hass, DOMAIN, {}) + yield diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 921439fab45..45300e2e66c 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -7,7 +7,7 @@ import urllib import zoneinfo import pytest -from todoist_api_python.models import Collaborator, Due, Label, Project, Task +from todoist_api_python.models import Due from homeassistant import setup from homeassistant.components.todoist.const import ( @@ -24,9 +24,10 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util +from .conftest import SUMMARY + from tests.typing import ClientSessionGenerator -SUMMARY = "A task" # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round TZ_NAME = "America/Regina" @@ -39,69 +40,6 @@ def set_time_zone(hass: HomeAssistant): hass.config.set_time_zone(TZ_NAME) -@pytest.fixture(name="due") -def mock_due() -> Due: - """Mock a todoist Task Due date/time.""" - return Due( - is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" - ) - - -@pytest.fixture(name="task") -def mock_task(due: Due) -> Task: - """Mock a todoist Task instance.""" - return Task( - assignee_id="1", - assigner_id="1", - comment_count=0, - is_completed=False, - content=SUMMARY, - created_at="2021-10-01T00:00:00", - creator_id="1", - description="A task", - due=due, - id="1", - labels=["Label1"], - order=1, - parent_id=None, - priority=1, - project_id="12345", - section_id=None, - url="https://todoist.com", - sync_id=None, - ) - - -@pytest.fixture(name="api") -def mock_api(task) -> AsyncMock: - """Mock the api state.""" - api = AsyncMock() - api.get_projects.return_value = [ - Project( - id="12345", - color="blue", - comment_count=0, - is_favorite=False, - name="Name", - is_shared=False, - url="", - is_inbox_project=False, - is_team_inbox=False, - order=1, - parent_id=None, - view_style="list", - ) - ] - api.get_labels.return_value = [ - Label(id="1", name="Label1", color="1", order=1, is_favorite=False) - ] - api.get_collaborators.return_value = [ - Collaborator(email="user@gmail.com", id="1", name="user") - ] - api.get_tasks.return_value = [task] - return api - - def get_events_url(entity: str, start: str, end: str) -> str: """Create a url to get events during the specified time range.""" return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" @@ -127,8 +65,8 @@ def mock_todoist_config() -> dict[str, Any]: return {} -@pytest.fixture(name="setup_integration", autouse=True) -async def mock_setup_integration( +@pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_platform( hass: HomeAssistant, api: AsyncMock, todoist_config: dict[str, Any], @@ -215,7 +153,7 @@ async def test_update_entity_for_calendar_with_due_date_in_the_future( assert state.attributes["end_time"] == expected_end_time -@pytest.mark.parametrize("setup_integration", [None]) +@pytest.mark.parametrize("setup_platform", [None]) async def test_failed_coordinator_update(hass: HomeAssistant, api: AsyncMock) -> None: """Test a failed data coordinator update is handled correctly.""" api.get_tasks.side_effect = Exception("API error") @@ -417,3 +355,44 @@ async def test_task_due_datetime( ) assert response.status == HTTPStatus.OK assert await response.json() == [] + + +@pytest.mark.parametrize( + ("due", "setup_platform"), + [ + ( + Due( + date="2023-03-30", + is_recurring=False, + string="Mar 30 6:00 PM", + datetime="2023-03-31T00:00:00Z", + timezone="America/Regina", + ), + None, + ) + ], +) +async def test_config_entry( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test for a calendar created with a config entry.""" + + await async_update_entity(hass, "calendar.name") + state = hass.states.get("calendar.name") + assert state + + client = await hass_client() + response = await client.get( + get_events_url( + "calendar.name", "2023-03-30T08:00:00.000Z", "2023-03-31T08:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [ + get_events_response( + {"dateTime": "2023-03-30T18:00:00-06:00"}, + {"dateTime": "2023-03-31T18:00:00-06:00"}, + ) + ] diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py new file mode 100644 index 00000000000..4175902da31 --- /dev/null +++ b/tests/components/todoist/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the todoist config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.todoist.const import DOMAIN +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TOKEN + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.fixture(autouse=True) +async def patch_api( + api: AsyncMock, +) -> None: + """Mock setup of the todoist integration.""" + with patch( + "homeassistant.components.todoist.config_flow.TodoistAPIAsync", return_value=api + ): + yield + + +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.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Todoist" + assert result2.get("data") == { + CONF_TOKEN: TOKEN, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED]) +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + 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_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "invalid_access_token"} + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + 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_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED]) +async def test_unknown_error(hass: HomeAssistant, api: AsyncMock) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + api.get_tasks.side_effect = ValueError("unexpected") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "unknown"} + + +async def test_already_configured(hass: HomeAssistant, setup_integration: None) -> None: + """Test that only a single instance can be configured.""" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py new file mode 100644 index 00000000000..cc64464df1d --- /dev/null +++ b/tests/components/todoist/test_init.py @@ -0,0 +1,47 @@ +"""Unit tests for the Todoist integration.""" +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.todoist.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_platforms() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.todoist.PLATFORMS", return_value=[] + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_load_unload( + hass: HomeAssistant, + setup_integration: None, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Test loading and unloading of the config entry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert todoist_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(todoist_config_entry.entry_id) + assert todoist_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) +async def test_init_failure( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + assert todoist_config_entry.state == ConfigEntryState.SETUP_RETRY From e8ed4c1ace2bf266e7e20f504244ee9c4d39008a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 01:56:02 -0500 Subject: [PATCH 411/984] Bump dbus-fast to 2.6.0 (#100163) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.4.0...v2.6.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 762117052f0..cd74d9b6c97 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", - "dbus-fast==2.4.0" + "dbus-fast==2.6.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fa9fade4031..5aaf114f1b8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.4.0 +dbus-fast==2.6.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index eaeab3d151a..f4af1e1ab6e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.4.0 +dbus-fast==2.6.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d81c6ecb22b..4dac8f2f402 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.4.0 +dbus-fast==2.6.0 # homeassistant.components.debugpy debugpy==1.6.7 From 80b03b4acb149cce6b7d2195416a53ff2a9d0d2b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 08:59:39 +0200 Subject: [PATCH 412/984] Adjust tasmota sensor device class and icon mapping (#100168) --- homeassistant/components/tasmota/sensor.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index ddcdb3e8c26..8365fd97ca4 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -88,12 +88,10 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { hc.SENSOR_COLOR_GREEN: {ICON: "mdi:palette"}, hc.SENSOR_COLOR_RED: {ICON: "mdi:palette"}, hc.SENSOR_CURRENT: { - ICON: "mdi:alpha-a-circle-outline", DEVICE_CLASS: SensorDeviceClass.CURRENT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_CURRENTNEUTRAL: { - ICON: "mdi:alpha-a-circle-outline", DEVICE_CLASS: SensorDeviceClass.CURRENT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, @@ -103,11 +101,14 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_DISTANCE: { - ICON: "mdi:leak", DEVICE_CLASS: SensorDeviceClass.DISTANCE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_ECO2: {ICON: "mdi:molecule-co2"}, + hc.SENSOR_ENERGY: { + DEVICE_CLASS: SensorDeviceClass.ENERGY, + STATE_CLASS: SensorStateClass.TOTAL, + }, hc.SENSOR_FREQUENCY: { DEVICE_CLASS: SensorDeviceClass.FREQUENCY, STATE_CLASS: SensorStateClass.MEASUREMENT, @@ -122,10 +123,7 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { }, hc.SENSOR_STATUS_IP: {ICON: "mdi:ip-network"}, hc.SENSOR_STATUS_LINK_COUNT: {ICON: "mdi:counter"}, - hc.SENSOR_MOISTURE: { - DEVICE_CLASS: SensorDeviceClass.MOISTURE, - ICON: "mdi:cup-water", - }, + hc.SENSOR_MOISTURE: {DEVICE_CLASS: SensorDeviceClass.MOISTURE}, hc.SENSOR_STATUS_MQTT_COUNT: {ICON: "mdi:counter"}, hc.SENSOR_PB0_3: {ICON: "mdi:flask"}, hc.SENSOR_PB0_5: {ICON: "mdi:flask"}, @@ -146,7 +144,6 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_POWERFACTOR: { - ICON: "mdi:alpha-f-circle-outline", DEVICE_CLASS: SensorDeviceClass.POWER_FACTOR, STATE_CLASS: SensorStateClass.MEASUREMENT, }, @@ -162,7 +159,7 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { DEVICE_CLASS: SensorDeviceClass.PRESSURE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, - hc.SENSOR_PROXIMITY: {DEVICE_CLASS: SensorDeviceClass.DISTANCE, ICON: "mdi:ruler"}, + hc.SENSOR_PROXIMITY: {ICON: "mdi:ruler"}, hc.SENSOR_REACTIVE_ENERGYEXPORT: {STATE_CLASS: SensorStateClass.TOTAL}, hc.SENSOR_REACTIVE_ENERGYIMPORT: {STATE_CLASS: SensorStateClass.TOTAL}, hc.SENSOR_REACTIVE_POWERUSAGE: { @@ -195,11 +192,10 @@ SENSOR_DEVICE_CLASS_ICON_MAP: dict[str, dict[str, Any]] = { hc.SENSOR_TOTAL_START_TIME: {ICON: "mdi:progress-clock"}, hc.SENSOR_TVOC: {ICON: "mdi:air-filter"}, hc.SENSOR_VOLTAGE: { - ICON: "mdi:alpha-v-circle-outline", + DEVICE_CLASS: SensorDeviceClass.VOLTAGE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_WEIGHT: { - ICON: "mdi:scale", DEVICE_CLASS: SensorDeviceClass.WEIGHT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, From da13afbd3c949298abd2c0c68fcb486ae06eccfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 12 Sep 2023 16:08:06 +0900 Subject: [PATCH 413/984] Add missing AEMET wind gust speed (#100157) --- homeassistant/components/aemet/sensor.py | 10 +++++++++- homeassistant/components/aemet/weather.py | 6 ++++++ tests/components/aemet/test_sensor.py | 3 +++ tests/components/aemet/test_weather.py | 3 +++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index f7aa6b35893..76e691a4682 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -30,6 +30,7 @@ from .const import ( ATTR_API_FORECAST_TEMP_LOW, ATTR_API_FORECAST_TIME, ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_MAX_SPEED, ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, @@ -99,6 +100,12 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Wind bearing", native_unit_of_measurement=DEGREE, ), + SensorEntityDescription( + key=ATTR_API_FORECAST_WIND_MAX_SPEED, + name="Wind max speed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + ), SensorEntityDescription( key=ATTR_API_FORECAST_WIND_SPEED, name="Wind speed", @@ -206,13 +213,14 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Wind max speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_WIND_SPEED, name="Wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index e3a1922c2f1..03f91a74740 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -42,6 +42,7 @@ from .const import ( ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_MAX_SPEED, ATTR_API_WIND_SPEED, ATTRIBUTION, DOMAIN, @@ -193,6 +194,11 @@ class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Return the wind bearing.""" return self.coordinator.data[ATTR_API_WIND_BEARING] + @property + def native_wind_gust_speed(self): + """Return the wind gust speed in native units.""" + return self.coordinator.data[ATTR_API_WIND_MAX_SPEED] + @property def native_wind_speed(self): """Return the wind speed.""" diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 8237987bf44..7b6f02f8b06 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -66,6 +66,9 @@ async def test_aemet_forecast_create_sensors( state = hass.states.get("sensor.aemet_hourly_forecast_wind_bearing") assert state is None + state = hass.states.get("sensor.aemet_hourly_forecast_wind_max_speed") + assert state is None + state = hass.states.get("sensor.aemet_hourly_forecast_wind_speed") assert state is None diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index ddcc29698fd..d0042faaaa0 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -26,6 +26,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECAST, @@ -58,6 +59,7 @@ async def test_aemet_weather( assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY @@ -101,6 +103,7 @@ async def test_aemet_weather_legacy( assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY From 5ba573a1b48ad51ed0daab40968526069bb393b0 Mon Sep 17 00:00:00 2001 From: Alex Yao <33379584+alexyao2015@users.noreply.github.com> Date: Tue, 12 Sep 2023 03:34:11 -0400 Subject: [PATCH 414/984] Add Life360 Location Update Button (#99559) Co-authored-by: Robert Resch Co-authored-by: alexyao2015 --- .coveragerc | 1 + homeassistant/components/life360/__init__.py | 2 +- homeassistant/components/life360/button.py | 56 +++++++++++++++++++ .../components/life360/coordinator.py | 6 ++ homeassistant/components/life360/strings.json | 7 +++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/life360/button.py diff --git a/.coveragerc b/.coveragerc index 4df91b250ed..686e3eaaadd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -654,6 +654,7 @@ omit = homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py homeassistant/components/life360/__init__.py + homeassistant/components/life360/button.py homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py homeassistant/components/lightwave/* diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 271f934e1c7..c6e0fad14c6 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -39,7 +39,7 @@ from .const import ( ) from .coordinator import Life360DataUpdateCoordinator, MissingLocReason -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.BUTTON] CONF_ACCOUNTS = "accounts" diff --git a/homeassistant/components/life360/button.py b/homeassistant/components/life360/button.py new file mode 100644 index 00000000000..6b460c8531c --- /dev/null +++ b/homeassistant/components/life360/button.py @@ -0,0 +1,56 @@ +"""Support for Life360 buttons.""" +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import Life360DataUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Life360 buttons.""" + coordinator: Life360DataUpdateCoordinator = hass.data[DOMAIN].coordinators[ + config_entry.entry_id + ] + for member_id, member in coordinator.data.members.items(): + async_add_entities( + [ + Life360UpdateLocationButton(coordinator, member.circle_id, member_id), + ] + ) + + +class Life360UpdateLocationButton( + CoordinatorEntity[Life360DataUpdateCoordinator], ButtonEntity +): + """Represent an Life360 Update Location button.""" + + _attr_has_entity_name = True + _attr_translation_key = "update_location" + + def __init__( + self, + coordinator: Life360DataUpdateCoordinator, + circle_id: str, + member_id: str, + ) -> None: + """Initialize a new Life360 Update Location button.""" + super().__init__(coordinator) + self._circle_id = circle_id + self._member_id = member_id + self._attr_unique_id = f"{member_id}-update-location" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, member_id)}, + name=coordinator.data.members[member_id].name, + ) + + async def async_press(self) -> None: + """Handle the button press.""" + await self.coordinator.update_location(self._circle_id, self._member_id) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py index 5ea64d3f81d..755fa1b8124 100644 --- a/homeassistant/components/life360/coordinator.py +++ b/homeassistant/components/life360/coordinator.py @@ -65,6 +65,7 @@ class Life360Member: at_loc_since: datetime battery_charging: bool battery_level: int + circle_id: str driving: bool entity_picture: str gps_accuracy: int @@ -118,6 +119,10 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): LOGGER.debug("%s: %s", exc.__class__.__name__, exc) raise UpdateFailed(exc) from exc + async def update_location(self, circle_id: str, member_id: str) -> None: + """Update location for given Circle and Member.""" + await self._retrieve_data("update_location", circle_id, member_id) + async def _async_update_data(self) -> Life360Data: """Get & process data from Life360.""" @@ -214,6 +219,7 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): dt_util.utc_from_timestamp(int(loc["since"])), bool(int(loc["charge"])), int(float(loc["battery"])), + circle_id, bool(int(loc["isDriving"])), member["avatar"], # Life360 reports accuracy in feet, but Device Tracker expects diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json index cc31ca64a08..343d9e95bb8 100644 --- a/homeassistant/components/life360/strings.json +++ b/homeassistant/components/life360/strings.json @@ -27,6 +27,13 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "entity": { + "button": { + "update_location": { + "name": "Update Location" + } + } + }, "options": { "step": { "init": { From 27c430bbac6e57ee84e420a7986cc54ee62476b8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 09:36:07 +0200 Subject: [PATCH 415/984] Use shorthand attributes in Smart meter texas (#99838) Co-authored-by: Robert Resch --- .../components/smart_meter_texas/sensor.py | 38 +++++-------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 7552f2c0697..d237daf01ca 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -47,50 +47,30 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_available = False def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.meter = meter - self._state = None - self._available = False - - @property - def name(self): - """Device Name.""" - return f"{ELECTRIC_METER} {self.meter.meter}" - - @property - def unique_id(self): - """Device Uniqueid.""" - return f"{self.meter.esiid}_{self.meter.meter}" - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def native_value(self): - """Get the latest reading.""" - return self._state + self._attr_name = f"{ELECTRIC_METER} {meter.meter}" + self._attr_unique_id = f"{meter.esiid}_{meter.meter}" @property def extra_state_attributes(self): """Return the device specific state attributes.""" - attributes = { + return { METER_NUMBER: self.meter.meter, ESIID: self.meter.esiid, CONF_ADDRESS: self.meter.address, } - return attributes @callback def _state_update(self): """Call when the coordinator has an update.""" - self._available = self.coordinator.last_update_success - if self._available: - self._state = self.meter.reading + self._attr_available = self.coordinator.last_update_success + if self._attr_available: + self._attr_native_value = self.meter.reading self.async_write_ha_state() async def async_added_to_hass(self): @@ -104,5 +84,5 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): return if last_state := await self.async_get_last_state(): - self._state = last_state.state - self._available = True + self._attr_native_value = last_state.state + self._attr_available = True From 5bcb4f07a00f27cf1ddd825a11cbf8985e0e2d11 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 09:58:05 +0200 Subject: [PATCH 416/984] Bump hatasmota to 0.7.3 (#100169) --- .../components/tasmota/manifest.json | 2 +- homeassistant/components/tasmota/sensor.py | 1 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_sensor.py | 214 ++++++++++++++++++ 5 files changed, 217 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index fa34665cd73..42fc849a2cf 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.2"] + "requirements": ["HATasmota==0.7.3"] } diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 8365fd97ca4..e718c0fdcf4 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -216,7 +216,6 @@ SENSOR_UNIT_MAP = { hc.LIGHT_LUX: LIGHT_LUX, hc.MASS_KILOGRAMS: UnitOfMass.KILOGRAMS, hc.PERCENTAGE: PERCENTAGE, - hc.POWER_FACTOR: None, hc.POWER_WATT: UnitOfPower.WATT, hc.PRESSURE_HPA: UnitOfPressure.HPA, hc.REACTIVE_POWER: POWER_VOLT_AMPERE_REACTIVE, diff --git a/requirements_all.txt b/requirements_all.txt index f4af1e1ab6e..4295701da22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.2 +HATasmota==0.7.3 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4dac8f2f402..22e385da3d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.2 +HATasmota==0.7.3 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index c14c7ffe53c..2f50a84ffdd 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -137,6 +137,27 @@ DICT_SENSOR_CONFIG_2 = { } } +NUMBERED_SENSOR_CONFIG = { + "sn": { + "Time": "2020-09-25T12:47:15", + "ANALOG": { + "Temperature1": 2.4, + "Temperature2": 2.4, + "Illuminance3": 2.4, + }, + "TempUnit": "C", + } +} + +NUMBERED_SENSOR_CONFIG_2 = { + "sn": { + "Time": "2020-09-25T12:47:15", + "ANALOG": { + "CTEnergy1": {"Energy": 0.5, "Power": 2300, "Voltage": 230, "Current": 10}, + }, + "TempUnit": "C", + } +} TEMPERATURE_SENSOR_CONFIG = { "sn": { @@ -343,6 +364,118 @@ TEMPERATURE_SENSOR_CONFIG = { }, ), ), + ( + NUMBERED_SENSOR_CONFIG, + [ + "sensor.tasmota_analog_temperature1", + "sensor.tasmota_analog_temperature2", + "sensor.tasmota_analog_illuminance3", + ], + ( + ( + '{"ANALOG":{"Temperature1":1.2,"Temperature2":3.4,' + '"Illuminance3": 5.6}}' + ), + ( + '{"StatusSNS":{"ANALOG":{"Temperature1": 7.8,"Temperature2": 9.0,' + '"Illuminance3":1.2}}}' + ), + ), + ( + { + "sensor.tasmota_analog_temperature1": { + "state": "1.2", + "attributes": { + "device_class": "temperature", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + }, + }, + "sensor.tasmota_analog_temperature2": { + "state": "3.4", + "attributes": { + "device_class": "temperature", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + }, + }, + "sensor.tasmota_analog_illuminance3": { + "state": "5.6", + "attributes": { + "device_class": "illuminance", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "lx", + }, + }, + }, + { + "sensor.tasmota_analog_temperature1": {"state": "7.8"}, + "sensor.tasmota_analog_temperature2": {"state": "9.0"}, + "sensor.tasmota_analog_illuminance3": {"state": "1.2"}, + }, + ), + ), + ( + NUMBERED_SENSOR_CONFIG_2, + [ + "sensor.tasmota_analog_ctenergy1_energy", + "sensor.tasmota_analog_ctenergy1_power", + "sensor.tasmota_analog_ctenergy1_voltage", + "sensor.tasmota_analog_ctenergy1_current", + ], + ( + ( + '{"ANALOG":{"CTEnergy1":' + '{"Energy":0.5,"Power":2300,"Voltage":230,"Current":10}}}' + ), + ( + '{"StatusSNS":{"ANALOG":{"CTEnergy1":' + '{"Energy":1.0,"Power":1150,"Voltage":230,"Current":5}}}}' + ), + ), + ( + { + "sensor.tasmota_analog_ctenergy1_energy": { + "state": "0.5", + "attributes": { + "device_class": "energy", + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + }, + "sensor.tasmota_analog_ctenergy1_power": { + "state": "2300", + "attributes": { + "device_class": "power", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "W", + }, + }, + "sensor.tasmota_analog_ctenergy1_voltage": { + "state": "230", + "attributes": { + "device_class": "voltage", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "V", + }, + }, + "sensor.tasmota_analog_ctenergy1_current": { + "state": "10", + "attributes": { + "device_class": "current", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "A", + }, + }, + }, + { + "sensor.tasmota_analog_ctenergy1_energy": {"state": "1.0"}, + "sensor.tasmota_analog_ctenergy1_power": {"state": "1150"}, + "sensor.tasmota_analog_ctenergy1_voltage": {"state": "230"}, + "sensor.tasmota_analog_ctenergy1_current": {"state": "5"}, + }, + ), + ), ], ) async def test_controlling_state_via_mqtt( @@ -409,6 +542,87 @@ async def test_controlling_state_via_mqtt( assert state.attributes.get(attribute) == expected +@pytest.mark.parametrize( + ("sensor_config", "entity_ids", "states"), + [ + ( + # The AS33935 energy sensor is not reporting energy in W + {"sn": {"Time": "2020-09-25T12:47:15", "AS3935": {"Energy": None}}}, + ["sensor.tasmota_as3935_energy"], + { + "sensor.tasmota_as3935_energy": { + "device_class": None, + "state_class": None, + "unit_of_measurement": None, + }, + }, + ), + ( + # The AS33935 energy sensor is not reporting energy in W + {"sn": {"Time": "2020-09-25T12:47:15", "LD2410": {"Energy": None}}}, + ["sensor.tasmota_ld2410_energy"], + { + "sensor.tasmota_ld2410_energy": { + "device_class": None, + "state_class": None, + "unit_of_measurement": None, + }, + }, + ), + ( + # Check other energy sensors work + {"sn": {"Time": "2020-09-25T12:47:15", "Other": {"Energy": None}}}, + ["sensor.tasmota_other_energy"], + { + "sensor.tasmota_other_energy": { + "device_class": "energy", + "state_class": "total", + "unit_of_measurement": "kWh", + }, + }, + ), + ], +) +async def test_quantity_override( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_tasmota, + sensor_config, + entity_ids, + states, +) -> None: + """Test quantity override for certain sensors.""" + entity_reg = er.async_get(hass) + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(sensor_config) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == "unavailable" + expected_state = states[entity_id] + for attribute, expected in expected_state.get("attributes", {}).items(): + assert state.attributes.get(attribute) == expected + + entry = entity_reg.async_get(entity_id) + assert entry.disabled is False + assert entry.disabled_by is None + assert entry.entity_category is None + + async def test_bad_indexed_sensor_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: From 0cd73e397bb243183e17627988eabd0363c9561f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:43:13 +0200 Subject: [PATCH 417/984] Bump tibdex/github-app-token from 1.8.2 to 2.0.0 (#100099) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a0a86d0e868..212cd0498b6 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: id: token # Pinned to a specific version of the action for security reasons # v1.7.0 - uses: tibdex/github-app-token@0d49dd721133f900ebd5e0dff2810704e8defbc6 + uses: tibdex/github-app-token@0914d50df753bbc42180d982a6550f195390069f with: app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} From fead9d3a9233f7b0d8a77369290d88cfc3f00739 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:45:35 +0200 Subject: [PATCH 418/984] Bump Ultraheat to version 0.5.7 (#100172) --- homeassistant/components/landisgyr_heat_meter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index a056f1f6564..1bf77d7ab51 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", "iot_class": "local_polling", - "requirements": ["ultraheat-api==0.5.1"] + "requirements": ["ultraheat-api==0.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4295701da22..ce2842b6316 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2607,7 +2607,7 @@ twitchAPI==3.10.0 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.5.1 +ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22e385da3d5..14831d4fa59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1913,7 +1913,7 @@ twitchAPI==3.10.0 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.5.1 +ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 From 71207e112ece14161ee65bdf8b9ff937697b0b16 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Sep 2023 10:59:50 +0200 Subject: [PATCH 419/984] Bring modbus naming in sync with standard (#99285) --- homeassistant/components/modbus/modbus.py | 28 +++++++++++++---------- tests/components/modbus/test_init.py | 10 ++++---- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 238df4466c4..a503b71593c 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -168,11 +168,12 @@ async def async_modbus_setup( async def async_write_register(service: ServiceCall) -> None: """Write Modbus registers.""" - unit = 0 + slave = 0 if ATTR_UNIT in service.data: - unit = int(float(service.data[ATTR_UNIT])) + slave = int(float(service.data[ATTR_UNIT])) + if ATTR_SLAVE in service.data: - unit = int(float(service.data[ATTR_SLAVE])) + slave = int(float(service.data[ATTR_SLAVE])) address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] hub = hub_collect[ @@ -180,29 +181,32 @@ async def async_modbus_setup( ] if isinstance(value, list): await hub.async_pb_call( - unit, address, [int(float(i)) for i in value], CALL_TYPE_WRITE_REGISTERS + slave, + address, + [int(float(i)) for i in value], + CALL_TYPE_WRITE_REGISTERS, ) else: await hub.async_pb_call( - unit, address, int(float(value)), CALL_TYPE_WRITE_REGISTER + slave, address, int(float(value)), CALL_TYPE_WRITE_REGISTER ) async def async_write_coil(service: ServiceCall) -> None: """Write Modbus coil.""" - unit = 0 + slave = 0 if ATTR_UNIT in service.data: - unit = int(float(service.data[ATTR_UNIT])) + slave = int(float(service.data[ATTR_UNIT])) if ATTR_SLAVE in service.data: - unit = int(float(service.data[ATTR_SLAVE])) + slave = int(float(service.data[ATTR_SLAVE])) address = service.data[ATTR_ADDRESS] state = service.data[ATTR_STATE] hub = hub_collect[ service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB ] if isinstance(state, list): - await hub.async_pb_call(unit, address, state, CALL_TYPE_WRITE_COILS) + await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS) else: - await hub.async_pb_call(unit, address, state, CALL_TYPE_WRITE_COIL) + await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COIL) for x_write in ( (SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int), @@ -405,10 +409,10 @@ class ModbusHub: return True def pb_call( - self, unit: int | None, address: int, value: int | list[int], use_call: str + self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusResponse | None: """Call sync. pymodbus.""" - kwargs = {"slave": unit} if unit else {} + kwargs = {"slave": slave} if slave else {} entry = self._pb_request[use_call] try: result: ModbusResponse = entry.func(address, value, **kwargs) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 6f88a4b7399..5d419ed28d5 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -566,17 +566,17 @@ SERVICE = "service" ], ) @pytest.mark.parametrize( - "do_unit", + "do_slave", [ - ATTR_UNIT, ATTR_SLAVE, + ATTR_UNIT, ], ) async def test_pb_service_write( hass: HomeAssistant, do_write, do_return, - do_unit, + do_slave, caplog: pytest.LogCaptureFixture, mock_modbus_with_pymodbus, ) -> None: @@ -591,7 +591,7 @@ async def test_pb_service_write( data = { ATTR_HUB: TEST_MODBUS_NAME, - do_unit: 17, + do_slave: 17, ATTR_ADDRESS: 16, do_write[DATA]: do_write[VALUE], } @@ -932,7 +932,7 @@ async def test_write_no_client(hass: HomeAssistant, mock_modbus) -> None: data = { ATTR_HUB: TEST_MODBUS_NAME, - ATTR_UNIT: 17, + ATTR_SLAVE: 17, ATTR_ADDRESS: 16, ATTR_STATE: True, } From 6b628f2d2904360e7ba50c965148fdde451bc618 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 14:02:50 +0200 Subject: [PATCH 420/984] Remove unnecessary block use of pylint disable in components a-o (#100190) --- homeassistant/components/cast/helpers.py | 2 +- homeassistant/components/fritz/common.py | 2 +- homeassistant/components/geniushub/__init__.py | 7 +++---- homeassistant/components/hdmi_cec/__init__.py | 5 +++-- homeassistant/components/limitlessled/light.py | 6 ++---- homeassistant/components/nx584/binary_sensor.py | 2 +- homeassistant/components/opentherm_gw/__init__.py | 3 +-- 7 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index c6a92c21fb4..8b8862ab318 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -214,7 +214,7 @@ class CastStatusListener( All following callbacks won't be forwarded. """ - # pylint: disable=protected-access + # pylint: disable-next=protected-access if self._cast_device._cast_info.is_audio_group: self._mz_mgr.remove_multizone(self._uuid) else: diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 76368175ca0..2abba137fbf 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -566,7 +566,7 @@ class FritzBoxTools( self.fritz_hosts.get_mesh_topology ) ): - # pylint: disable=broad-exception-raised + # pylint: disable-next=broad-exception-raised raise Exception("Mesh supported but empty topology reported") except FritzActionError: self.mesh_role = MeshRoles.SLAVE diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index bed622eebf6..955c76fe0fc 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -212,11 +212,10 @@ class GeniusBroker: def make_debug_log_entries(self) -> None: """Make any useful debug log entries.""" - # pylint: disable=protected-access _LOGGER.debug( "Raw JSON: \n\nclient._zones = %s \n\nclient._devices = %s", - self.client._zones, - self.client._devices, + self.client._zones, # pylint: disable=protected-access + self.client._devices, # pylint: disable=protected-access ) @@ -309,7 +308,7 @@ class GeniusZone(GeniusEntity): mode = payload["data"][ATTR_ZONE_MODE] - # pylint: disable=protected-access + # pylint: disable-next=protected-access if mode == "footprint" and not self._zone._has_pir: raise TypeError( f"'{self.entity_id}' cannot support footprint mode (it has no PIR)" diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 459f03edfbb..19621e28d03 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -123,11 +123,12 @@ SERVICE_SELECT_DEVICE = "select_device" SERVICE_POWER_ON = "power_on" SERVICE_STANDBY = "standby" -# pylint: disable=unnecessary-lambda DEVICE_SCHEMA: vol.Schema = vol.Schema( { vol.All(cv.positive_int): vol.Any( - lambda devices: DEVICE_SCHEMA(devices), cv.string + # pylint: disable-next=unnecessary-lambda + lambda devices: DEVICE_SCHEMA(devices), + cv.string, ) } ) diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 6677768dd00..c1dfeda172c 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -182,20 +182,18 @@ def state(new_state): def wrapper(self: LimitlessLEDGroup, **kwargs: Any) -> None: """Wrap a group state change.""" - # pylint: disable=protected-access - pipeline = Pipeline() transition_time = DEFAULT_TRANSITION if self.effect == EFFECT_COLORLOOP: self.group.stop() - self._attr_effect = None + self._attr_effect = None # pylint: disable=protected-access # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) # Do group type-specific work. function(self, transition_time, pipeline, **kwargs) # Update state. - self._attr_is_on = new_state + self._attr_is_on = new_state # pylint: disable=protected-access self.group.enqueue(pipeline) self.schedule_update_ha_state() diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 853f5686831..ca55ea25c40 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -131,9 +131,9 @@ class NX584Watcher(threading.Thread): def _process_zone_event(self, event): zone = event["zone"] - # pylint: disable=protected-access if not (zone_sensor := self._zone_sensors.get(zone)): return + # pylint: disable-next=protected-access zone_sensor._zone["state"] = event["zone_state"] zone_sensor.schedule_update_ha_state() diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 0b8d4693cb8..cd8b98880d5 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -163,8 +163,7 @@ def register_services(hass: HomeAssistant) -> None: vol.Required(ATTR_GW_ID): vol.All( cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS]) ), - # pylint: disable=unnecessary-lambda - vol.Optional(ATTR_DATE, default=lambda: date.today()): cv.date, + vol.Optional(ATTR_DATE, default=date.today): cv.date, vol.Optional(ATTR_TIME, default=lambda: datetime.now().time()): cv.time, } ) From 26ada307208379cc3933d522ff886b80e999cde9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Sep 2023 14:12:45 +0200 Subject: [PATCH 421/984] Remove default from deprecated close_comm_on_error (#100188) --- homeassistant/components/modbus/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index b4258d47d5e..a3c8928caaf 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -336,7 +336,7 @@ MODBUS_SCHEMA = vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string, vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, - vol.Optional(CONF_CLOSE_COMM_ON_ERROR, default=True): cv.boolean, + vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, vol.Optional(CONF_RETRIES, default=3): cv.positive_int, vol.Optional(CONF_RETRY_ON_EMPTY, default=False): cv.boolean, From 1ca505c228fcb66c1b0704762cb79453156cdc44 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 14:58:03 +0200 Subject: [PATCH 422/984] Use shorthand attributes in Wiffi (#99919) --- homeassistant/components/wiffi/__init__.py | 31 ++++----------- .../components/wiffi/binary_sensor.py | 10 ++--- homeassistant/components/wiffi/sensor.py | 38 ++++++++----------- 3 files changed, 27 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 11ef186ba15..3a35ec1ed29 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -144,7 +144,8 @@ class WiffiEntity(Entity): def __init__(self, device, metric, options): """Initialize the base elements of a wiffi entity.""" self._id = generate_unique_id(device, metric) - self._device_info = DeviceInfo( + self._attr_unique_id = self._id + self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, identifiers={(DOMAIN, device.mac_address)}, manufacturer="stall.biz", @@ -153,7 +154,7 @@ class WiffiEntity(Entity): sw_version=device.sw_version, configuration_url=device.configuration_url, ) - self._name = metric.description + self._attr_name = metric.description self._expiration_date = None self._value = None self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) @@ -173,26 +174,6 @@ class WiffiEntity(Entity): ) ) - @property - def device_info(self): - """Return wiffi device info which is shared between all entities of a device.""" - return self._device_info - - @property - def unique_id(self): - """Return unique id for entity.""" - return self._id - - @property - def name(self): - """Return entity name.""" - return self._name - - @property - def available(self): - """Return true if value is valid.""" - return self._value is not None - def reset_expiration_date(self): """Reset value expiration date. @@ -221,8 +202,10 @@ class WiffiEntity(Entity): def _is_measurement_entity(self): """Measurement entities have a value in present time.""" - return not self._name.endswith("_gestern") and not self._is_metered_entity() + return ( + not self._attr_name.endswith("_gestern") and not self._is_metered_entity() + ) def _is_metered_entity(self): """Metered entities have a value that keeps increasing until reset.""" - return self._name.endswith("_pro_h") or self._name.endswith("_heute") + return self._attr_name.endswith("_pro_h") or self._attr_name.endswith("_heute") diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index d0647b25297..cb1e1da41d8 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -39,13 +39,13 @@ class BoolEntity(WiffiEntity, BinarySensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._value = metric.value + self._attr_is_on = metric.value self.reset_expiration_date() @property - def is_on(self): - """Return the state of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_is_on is not None @callback def _update_value_callback(self, device, metric): @@ -54,5 +54,5 @@ class BoolEntity(WiffiEntity, BinarySensorEntity): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._value = metric.value + self._attr_is_on = metric.value self.async_write_ha_state() diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 1036ac7986f..e460a346bd7 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -69,11 +69,13 @@ class NumberEntity(WiffiEntity, SensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._device_class = UOM_TO_DEVICE_CLASS_MAP.get(metric.unit_of_measurement) - self._unit_of_measurement = UOM_MAP.get( + self._attr_device_class = UOM_TO_DEVICE_CLASS_MAP.get( + metric.unit_of_measurement + ) + self._attr_native_unit_of_measurement = UOM_MAP.get( metric.unit_of_measurement, metric.unit_of_measurement ) - self._value = metric.value + self._attr_native_value = metric.value if self._is_measurement_entity(): self._attr_state_class = SensorStateClass.MEASUREMENT @@ -83,19 +85,9 @@ class NumberEntity(WiffiEntity, SensorEntity): self.reset_expiration_date() @property - def device_class(self): - """Return the automatically determined device class.""" - return self._device_class - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the value of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_native_value is not None @callback def _update_value_callback(self, device, metric): @@ -104,11 +96,11 @@ class NumberEntity(WiffiEntity, SensorEntity): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._unit_of_measurement = UOM_MAP.get( + self._attr_native_unit_of_measurement = UOM_MAP.get( metric.unit_of_measurement, metric.unit_of_measurement ) - self._value = metric.value + self._attr_native_value = metric.value self.async_write_ha_state() @@ -119,13 +111,13 @@ class StringEntity(WiffiEntity, SensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._value = metric.value + self._attr_native_value = metric.value self.reset_expiration_date() @property - def native_value(self): - """Return the value of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_native_value is not None @callback def _update_value_callback(self, device, metric): @@ -134,5 +126,5 @@ class StringEntity(WiffiEntity, SensorEntity): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._value = metric.value + self._attr_native_value = metric.value self.async_write_ha_state() From 1cf2f2f8b82564eac9f73c3b63cfde14c056f281 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:00:11 +0200 Subject: [PATCH 423/984] Use shorthand attributes in Songpal (#99849) --- .../components/songpal/media_player.py | 31 ++++++------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 2d2c5892636..79fab9a2651 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -110,14 +110,14 @@ class SongpalEntity(MediaPlayerEntity): self._model = None self._state = False - self._available = False + self._attr_available = False self._initialized = False self._volume_control = None self._volume_min = 0 self._volume_max = 1 self._volume = 0 - self._is_muted = False + self._attr_is_volume_muted = False self._active_source = None self._sources = {} @@ -137,7 +137,7 @@ class SongpalEntity(MediaPlayerEntity): async def _volume_changed(volume: VolumeChange): _LOGGER.debug("Volume changed: %s", volume) self._volume = volume.volume - self._is_muted = volume.mute + self._attr_is_volume_muted = volume.mute self.async_write_ha_state() async def _source_changed(content: ContentChange): @@ -161,13 +161,13 @@ class SongpalEntity(MediaPlayerEntity): self._dev.endpoint, ) _LOGGER.debug("Disconnected: %s", connect.exception) - self._available = False + self._attr_available = False self.async_write_ha_state() # Try to reconnect forever, a successful reconnect will initialize # the websocket connection again. delay = INITIAL_RETRY_DELAY - while not self._available: + while not self._attr_available: _LOGGER.debug("Trying to reconnect in %s seconds", delay) await asyncio.sleep(delay) @@ -220,11 +220,6 @@ class SongpalEntity(MediaPlayerEntity): sw_version=self._sysinfo.version, ) - @property - def available(self): - """Return availability of the device.""" - return self._available - async def async_set_sound_setting(self, name, value): """Change a setting on the device.""" _LOGGER.debug("Calling set_sound_setting with %s: %s", name, value) @@ -243,7 +238,7 @@ class SongpalEntity(MediaPlayerEntity): volumes = await self._dev.get_volume_information() if not volumes: _LOGGER.error("Got no volume controls, bailing out") - self._available = False + self._attr_available = False return if len(volumes) > 1: @@ -256,7 +251,7 @@ class SongpalEntity(MediaPlayerEntity): self._volume_min = volume.minVolume self._volume = volume.volume self._volume_control = volume - self._is_muted = self._volume_control.is_muted + self._attr_is_volume_muted = self._volume_control.is_muted status = await self._dev.get_power() self._state = status.status @@ -273,11 +268,11 @@ class SongpalEntity(MediaPlayerEntity): _LOGGER.debug("Active source: %s", self._active_source) - self._available = True + self._attr_available = True except SongpalException as ex: _LOGGER.error("Unable to update: %s", ex) - self._available = False + self._attr_available = False async def async_select_source(self, source: str) -> None: """Select source.""" @@ -309,8 +304,7 @@ class SongpalEntity(MediaPlayerEntity): @property def volume_level(self): """Return volume level.""" - volume = self._volume / self._volume_max - return volume + return self._volume / self._volume_max async def async_set_volume_level(self, volume: float) -> None: """Set volume level.""" @@ -354,8 +348,3 @@ class SongpalEntity(MediaPlayerEntity): """Mute or unmute the device.""" _LOGGER.debug("Set mute: %s", mute) return await self._volume_control.set_mute(mute) - - @property - def is_volume_muted(self): - """Return whether the device is muted.""" - return self._is_muted From 1ccf9cc400bc48909abbdfc2be0591d783dbb7a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:02:29 +0200 Subject: [PATCH 424/984] Use shorthand attributes in Squeezebox (#99863) --- .../components/squeezebox/media_player.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index c77126e4377..03457c6a5c0 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -1,6 +1,7 @@ """Support for interfacing to the Logitech SqueezeBox API.""" from __future__ import annotations +from datetime import datetime import json import logging from typing import Any @@ -238,17 +239,17 @@ class SqueezeBoxEntity(MediaPlayerEntity): ) _attr_has_entity_name = True _attr_name = None + _last_update: datetime | None = None + _attr_available = True def __init__(self, player): """Initialize the SqueezeBox device.""" self._player = player - self._last_update = None self._query_result = {} - self._available = True self._remove_dispatcher = None - self._attr_unique_id = format_mac(self._player.player_id) + self._attr_unique_id = format_mac(player.player_id) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, name=self._player.name + identifiers={(DOMAIN, self._attr_unique_id)}, name=player.name ) @property @@ -262,16 +263,11 @@ class SqueezeBoxEntity(MediaPlayerEntity): return squeezebox_attr - @property - def available(self): - """Return True if device connected to LMS server.""" - return self._available - @callback def rediscovered(self, unique_id, connected): """Make a player available again.""" if unique_id == self.unique_id and connected: - self._available = True + self._attr_available = True _LOGGER.info("Player %s is available again", self.name) self._remove_dispatcher() @@ -287,14 +283,14 @@ class SqueezeBoxEntity(MediaPlayerEntity): async def async_update(self) -> None: """Update the Player() object.""" # only update available players, newly available players will be rediscovered and marked available - if self._available: + if self._attr_available: last_media_position = self.media_position await self._player.async_update() if self.media_position != last_media_position: self._last_update = utcnow() if self._player.connected is False: _LOGGER.info("Player %s is not available", self.name) - self._available = False + self._attr_available = False # start listening for restored players self._remove_dispatcher = async_dispatcher_connect( From b5275016d453ce7173a786c1c3fb8542948a57e2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:08:18 +0200 Subject: [PATCH 425/984] Use shorthand attributes in Twinkly (#99891) --- homeassistant/components/twinkly/light.py | 63 ++++++----------------- 1 file changed, 15 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 5ddd22c8a23..66f764f17f6 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping import logging from typing import Any @@ -62,6 +61,8 @@ async def async_setup_entry( class TwinklyLight(LightEntity): """Implementation of the light for the Twinkly service.""" + _attr_icon = "mdi:string-lights" + def __init__( self, conf: ConfigEntry, @@ -69,7 +70,7 @@ class TwinklyLight(LightEntity): device_info, ) -> None: """Initialize a TwinklyLight entity.""" - self._id = conf.data[CONF_ID] + self._attr_unique_id: str = conf.data[CONF_ID] self._conf = conf if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW: @@ -93,64 +94,30 @@ class TwinklyLight(LightEntity): self._client = client # Set default state before any update - self._is_on = False - self._is_available = False - self._attributes: dict[Any, Any] = {} + self._attr_is_on = False + self._attr_available = False self._current_movie: dict[Any, Any] = {} self._movies: list[Any] = [] self._software_version = "" # We guess that most devices are "new" and support effects self._attr_supported_features = LightEntityFeature.EFFECT - @property - def available(self) -> bool: - """Get a boolean which indicates if this entity is currently available.""" - return self._is_available - - @property - def unique_id(self) -> str | None: - """Id of the device.""" - return self._id - @property def name(self) -> str: """Name of the device.""" return self._name if self._name else "Twinkly light" - @property - def model(self) -> str: - """Name of the device.""" - return self._model - - @property - def icon(self) -> str: - """Icon of the device.""" - return "mdi:string-lights" - @property def device_info(self) -> DeviceInfo | None: """Get device specific attributes.""" return DeviceInfo( - identifiers={(DOMAIN, self._id)}, + identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="LEDWORKS", - model=self.model, + model=self._model, name=self.name, sw_version=self._software_version, ) - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._is_on - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return device specific state attributes.""" - - attributes = self._attributes - - return attributes - @property def effect(self) -> str | None: """Return the current effect.""" @@ -246,7 +213,7 @@ class TwinklyLight(LightEntity): await self._client.set_current_movie(int(movie_id)) await self._client.set_mode("movie") self._client.default_mode = "movie" - if not self._is_on: + if not self._attr_is_on: await self._client.turn_on() async def async_turn_off(self, **kwargs: Any) -> None: @@ -258,7 +225,7 @@ class TwinklyLight(LightEntity): _LOGGER.debug("Updating '%s'", self._client.host) try: - self._is_on = await self._client.is_on() + self._attr_is_on = await self._client.is_on() brightness = await self._client.get_brightness() brightness_value = ( @@ -266,7 +233,7 @@ class TwinklyLight(LightEntity): ) self._attr_brightness = ( - int(round(brightness_value * 2.55)) if self._is_on else 0 + int(round(brightness_value * 2.55)) if self._attr_is_on else 0 ) device_info = await self._client.get_details() @@ -289,7 +256,7 @@ class TwinklyLight(LightEntity): self._conf, data={ CONF_HOST: self._client.host, # this cannot change - CONF_ID: self._id, # this cannot change + CONF_ID: self._attr_unique_id, # this cannot change CONF_NAME: self._name, CONF_MODEL: self._model, }, @@ -299,20 +266,20 @@ class TwinklyLight(LightEntity): await self.async_update_movies() await self.async_update_current_movie() - if not self._is_available: + if not self._attr_available: _LOGGER.info("Twinkly '%s' is now available", self._client.host) # We don't use the echo API to track the availability since # we already have to pull the device to get its state. - self._is_available = True + self._attr_available = True except (asyncio.TimeoutError, ClientError): # We log this as "info" as it's pretty common that the Christmas # light are not reachable in July - if self._is_available: + if self._attr_available: _LOGGER.info( "Twinkly '%s' is not reachable (client error)", self._client.host ) - self._is_available = False + self._attr_available = False async def async_update_movies(self) -> None: """Update the list of movies (effects).""" From 76c3a638c45bfd0ef2387e2318343f8653fc8fcf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:17:57 +0200 Subject: [PATCH 426/984] Use shorthand attributes in Smarttub (#99839) --- .../components/smarttub/binary_sensor.py | 16 ++-------- homeassistant/components/smarttub/climate.py | 6 +--- homeassistant/components/smarttub/entity.py | 31 ++++++------------- homeassistant/components/smarttub/light.py | 14 ++------- homeassistant/components/smarttub/switch.py | 6 +--- 5 files changed, 17 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index a1159bcc0ef..99037cd623c 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -76,19 +76,13 @@ class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + # This seems to be very noisy and not generally useful, so disable by default. + _attr_entity_registry_enabled_default = False def __init__(self, coordinator, spa): """Initialize the entity.""" super().__init__(coordinator, spa, "Online", "online") - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry. - - This seems to be very noisy and not generally useful, so disable by default. - """ - return False - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" @@ -108,11 +102,7 @@ class SmartTubReminder(SmartTubEntity, BinarySensorEntity): f"{reminder.name.title()} Reminder", ) self.reminder_id = reminder.id - - @property - def unique_id(self): - """Return a unique id for this sensor.""" - return f"{self.spa.id}-reminder-{self.reminder_id}" + self._attr_unique_id = f"{spa.id}-reminder-{reminder.id}" @property def reminder(self) -> SpaReminder: diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index a938bde6fd1..b2d4fbf17c4 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -64,6 +64,7 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_preset_modes = list(PRESET_MODES.values()) def __init__(self, coordinator, spa): """Initialize the entity.""" @@ -104,11 +105,6 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): """Return the current preset mode.""" return PRESET_MODES[self.spa_status.heat_mode] - @property - def preset_modes(self): - """Return the available preset modes.""" - return list(PRESET_MODES.values()) - @property def current_temperature(self): """Return the current water temperature.""" diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 7f2a739c26e..6e6cb00a7d3 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -25,27 +25,14 @@ class SmartTubEntity(CoordinatorEntity): super().__init__(coordinator) self.spa = spa - self._entity_name = entity_name - - @property - def unique_id(self) -> str: - """Return a unique id for the entity.""" - return f"{self.spa.id}-{self._entity_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.spa.id)}, - manufacturer=self.spa.brand, - model=self.spa.model, + self._attr_unique_id = f"{spa.id}-{entity_name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, spa.id)}, + manufacturer=spa.brand, + model=spa.model, ) - - @property - def name(self) -> str: - """Return the name of the entity.""" spa_name = get_spa_name(self.spa) - return f"{spa_name} {self._entity_name}" + self._attr_name = f"{spa_name} {entity_name}" @property def spa_status(self) -> smarttub.SpaState: @@ -57,12 +44,12 @@ class SmartTubEntity(CoordinatorEntity): class SmartTubSensorBase(SmartTubEntity): """Base class for SmartTub sensors.""" - def __init__(self, coordinator, spa, sensor_name, attr_name): + def __init__(self, coordinator, spa, sensor_name, state_key): """Initialize the entity.""" super().__init__(coordinator, spa, sensor_name) - self._attr_name = attr_name + self._state_key = state_key @property def _state(self): """Retrieve the underlying state from the spa.""" - return getattr(self.spa_status, self._attr_name) + return getattr(self.spa_status, self._state_key) diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py index f7e229449e0..d89cdba3367 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -53,23 +53,15 @@ class SmartTubLight(SmartTubEntity, LightEntity): """Initialize the entity.""" super().__init__(coordinator, light.spa, "light") self.light_zone = light.zone + self._attr_unique_id = f"{super().unique_id}-{light.zone}" + spa_name = get_spa_name(self.spa) + self._attr_name = f"{spa_name} Light {light.zone}" @property def light(self) -> SpaLight: """Return the underlying SpaLight object for this entity.""" return self.coordinator.data[self.spa.id][ATTR_LIGHTS][self.light_zone] - @property - def unique_id(self) -> str: - """Return a unique ID for this light entity.""" - return f"{super().unique_id}-{self.light_zone}" - - @property - def name(self) -> str: - """Return a name for this light entity.""" - spa_name = get_spa_name(self.spa) - return f"{spa_name} Light {self.light_zone}" - @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index e105963bc01..aeeca46aaa9 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -38,17 +38,13 @@ class SmartTubPump(SmartTubEntity, SwitchEntity): super().__init__(coordinator, pump.spa, "pump") self.pump_id = pump.id self.pump_type = pump.type + self._attr_unique_id = f"{super().unique_id}-{pump.id}" @property def pump(self) -> SpaPump: """Return the underlying SpaPump object for this entity.""" return self.coordinator.data[self.spa.id][ATTR_PUMPS][self.pump_id] - @property - def unique_id(self) -> str: - """Return a unique ID for this pump entity.""" - return f"{super().unique_id}-{self.pump_id}" - @property def name(self) -> str: """Return a name for this pump entity.""" From 6b265120b3f12185dc04b3fcc2ebe963836dd10d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 15:22:37 +0200 Subject: [PATCH 427/984] Fix entity name attribute on mqtt entity is not removed on update (#100187) Fix entity name attribute is not remove on update --- homeassistant/components/mqtt/mixins.py | 5 +++ tests/components/mqtt/test_mixins.py | 60 ++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index ceccfa5adc8..795eb30e8e2 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -1137,6 +1137,11 @@ class MqttEntity( elif not self._default_to_device_class_name(): # Assign the default name self._attr_name = self._default_name + elif hasattr(self, "_attr_name"): + # An entity name was not set in the config + # don't set the name attribute and derive + # the name from the device_class + delattr(self, "_attr_name") if CONF_DEVICE in config: device_name: str if CONF_NAME not in config[CONF_DEVICE]: diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 0647721b4d0..1ca9bf07d72 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components import mqtt, sensor from homeassistant.components.mqtt.sensor import DEFAULT_NAME as DEFAULT_SENSOR_NAME from homeassistant.const import ( + ATTR_FRIENDLY_NAME, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, Platform, @@ -324,7 +325,6 @@ async def test_default_entity_and_device_name( This is a test helper for the _setup_common_attributes_from_config mixin. """ - # mqtt_mock = await mqtt_mock_entry() events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) hass.state = CoreState.starting @@ -352,3 +352,61 @@ async def test_default_entity_and_device_name( # Assert that an issues ware registered assert len(events) == issue_events + + +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_name_attribute_is_set_or_not( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test frendly name with device_class set. + + This is a test helper for the _setup_common_attributes_from_config mixin. + """ + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Gate", "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Gate" + + # Remove the name in a discovery update + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Door" + + # Set the name to `null` in a discovery update + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": null, "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) is None From e143bdf2f575f8732ea7d810f50968db572a2220 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:23:12 +0200 Subject: [PATCH 428/984] Use shorthand attributes in Vera (#99893) --- .../components/vera/binary_sensor.py | 10 ++---- homeassistant/components/vera/climate.py | 6 +--- homeassistant/components/vera/light.py | 36 ++++++------------- homeassistant/components/vera/lock.py | 16 +++------ homeassistant/components/vera/scene.py | 7 +--- homeassistant/components/vera/sensor.py | 25 +++++-------- homeassistant/components/vera/switch.py | 14 +++----- 7 files changed, 34 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index 57b47e6c742..82c7d187b88 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -32,20 +32,16 @@ async def async_setup_entry( class VeraBinarySensor(VeraDevice[veraApi.VeraBinarySensor], BinarySensorEntity): """Representation of a Vera Binary Sensor.""" + _attr_is_on = False + def __init__( self, vera_device: veraApi.VeraBinarySensor, controller_data: ControllerData ) -> None: """Initialize the binary_sensor.""" - self._state = False VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - @property - def is_on(self) -> bool | None: - """Return true if sensor is on.""" - return self._state - def update(self) -> None: """Get the latest data and update the state.""" super().update() - self._state = self.vera_device.is_tripped + self._attr_is_on = self.vera_device.is_tripped diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 164da079ac1..f58ae083f72 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -46,6 +46,7 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): """Representation of a Vera Thermostat.""" _attr_hvac_modes = SUPPORT_HVAC + _attr_fan_modes = FAN_OPERATION_LIST _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) @@ -79,11 +80,6 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): return FAN_ON return FAN_AUTO - @property - def fan_modes(self) -> list[str] | None: - """Return a list of available fan modes.""" - return FAN_OPERATION_LIST - def set_fan_mode(self, fan_mode: str) -> None: """Set new target temperature.""" if fan_mode == FAN_ON: diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index fa017be475e..c76cd76ad19 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -41,31 +41,22 @@ async def async_setup_entry( class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): """Representation of a Vera Light, including dimmable.""" + _attr_is_on = False + _attr_hs_color: tuple[float, float] | None = None + _attr_brightness: int | None = None + def __init__( self, vera_device: veraApi.VeraDimmer, controller_data: ControllerData ) -> None: """Initialize the light.""" - self._state = False - self._color: tuple[float, float] | None = None - self._brightness = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - @property - def brightness(self) -> int | None: - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self) -> tuple[float, float] | None: - """Return the color of the light.""" - return self._color - @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self.vera_device.is_dimmable: - if self._color: + if self._attr_hs_color: return ColorMode.HS return ColorMode.BRIGHTNESS return ColorMode.ONOFF @@ -77,7 +68,7 @@ class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_HS_COLOR in kwargs and self._color: + if ATTR_HS_COLOR in kwargs and self._attr_hs_color: rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) self.vera_device.set_color(rgb) elif ATTR_BRIGHTNESS in kwargs and self.vera_device.is_dimmable: @@ -85,27 +76,22 @@ class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): else: self.vera_device.switch_on() - self._state = True + self._attr_is_on = True self.schedule_update_ha_state(True) def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" self.vera_device.switch_off() - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._state - def update(self) -> None: """Call to update state.""" super().update() - self._state = self.vera_device.is_switched_on() + self._attr_is_on = self.vera_device.is_switched_on() if self.vera_device.is_dimmable: # If it is dimmable, both functions exist. In case color # is not supported, it will return None - self._brightness = self.vera_device.get_brightness() + self._attr_brightness = self.vera_device.get_brightness() rgb = self.vera_device.get_color() - self._color = color_util.color_RGB_to_hs(*rgb) if rgb else None + self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) if rgb else None diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 50710030b8f..8994076ca31 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -7,7 +7,7 @@ import pyvera as veraApi from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -41,24 +41,18 @@ class VeraLock(VeraDevice[veraApi.VeraLock], LockEntity): self, vera_device: veraApi.VeraLock, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - self._state: str | None = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def lock(self, **kwargs: Any) -> None: """Lock the device.""" self.vera_device.lock() - self._state = STATE_LOCKED + self._attr_is_locked = True def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self.vera_device.unlock() - self._state = STATE_UNLOCKED - - @property - def is_locked(self) -> bool | None: - """Return true if device is on.""" - return self._state == STATE_LOCKED + self._attr_is_locked = False @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -91,6 +85,4 @@ class VeraLock(VeraDevice[veraApi.VeraLock], LockEntity): def update(self) -> None: """Update state by the Vera device callback.""" - self._state = ( - STATE_LOCKED if self.vera_device.is_locked(True) else STATE_UNLOCKED - ) + self._attr_is_locked = self.vera_device.is_locked(True) diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py index c1381f488dd..daa3a6fc530 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -37,7 +37,7 @@ class VeraScene(Scene): self.vera_scene = vera_scene self.controller = controller_data.controller - self._name = self.vera_scene.name + self._attr_name = self.vera_scene.name # Append device id to prevent name clashes in HA. self.vera_id = VERA_ID_FORMAT.format( slugify(vera_scene.name), vera_scene.scene_id @@ -51,11 +51,6 @@ class VeraScene(Scene): """Activate the scene.""" self.vera_scene.activate() - @property - def name(self) -> str: - """Return the name of the scene.""" - return self._name - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the scene.""" diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index b493f9aac3d..942ebc77acd 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -21,7 +21,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from . import VeraDevice from .common import ControllerData, get_controller_data @@ -52,17 +51,11 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): self, vera_device: veraApi.VeraSensor, controller_data: ControllerData ) -> None: """Initialize the sensor.""" - self.current_value: StateType = None self._temperature_units: str | None = None self.last_changed_time = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - @property - def native_value(self) -> StateType: - """Return the name of the sensor.""" - return self.current_value - @property def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" @@ -96,7 +89,7 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): """Update the state.""" super().update() if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - self.current_value = self.vera_device.temperature + self._attr_native_value = self.vera_device.temperature vera_temp_units = self.vera_device.vera_controller.temperature_units @@ -106,24 +99,24 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): self._temperature_units = UnitOfTemperature.CELSIUS elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - self.current_value = self.vera_device.light + self._attr_native_value = self.vera_device.light elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: - self.current_value = self.vera_device.light + self._attr_native_value = self.vera_device.light elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - self.current_value = self.vera_device.humidity + self._attr_native_value = self.vera_device.humidity elif self.vera_device.category == veraApi.CATEGORY_SCENE_CONTROLLER: controller = cast(veraApi.VeraSceneController, self.vera_device) value = controller.get_last_scene_id(True) time = controller.get_last_scene_time(True) if time == self.last_changed_time: - self.current_value = None + self._attr_native_value = None else: - self.current_value = value + self._attr_native_value = value self.last_changed_time = time elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: - self.current_value = self.vera_device.power + self._attr_native_value = self.vera_device.power elif self.vera_device.is_trippable: tripped = self.vera_device.is_tripped - self.current_value = "Tripped" if tripped else "Not Tripped" + self._attr_native_value = "Tripped" if tripped else "Not Tripped" else: - self.current_value = "Unknown" + self._attr_native_value = "Unknown" diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index b146ed39ade..011f777b1b2 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -34,32 +34,28 @@ async def async_setup_entry( class VeraSwitch(VeraDevice[veraApi.VeraSwitch], SwitchEntity): """Representation of a Vera Switch.""" + _attr_is_on = False + def __init__( self, vera_device: veraApi.VeraSwitch, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - self._state = False VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def turn_on(self, **kwargs: Any) -> None: """Turn device on.""" self.vera_device.switch_on() - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn device off.""" self.vera_device.switch_off() - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._state - def update(self) -> None: """Update device state.""" super().update() - self._state = self.vera_device.is_switched_on() + self._attr_is_on = self.vera_device.is_switched_on() From fabb098ec3b72d4874609ccbd6a6963c5f839dcd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 15:39:11 +0200 Subject: [PATCH 429/984] Simplify WS command entity/source (#99439) --- .../components/websocket_api/commands.py | 57 +++++------- .../components/websocket_api/test_commands.py | 89 +------------------ 2 files changed, 26 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index ea21b7b5eba..66866197081 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -11,7 +11,7 @@ from typing import Any, cast import voluptuous as vol from homeassistant.auth.models import User -from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_READ +from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.const import ( EVENT_STATE_CHANGED, MATCH_ALL, @@ -52,7 +52,6 @@ from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages from .connection import ActiveConnection -from .const import ERR_NOT_FOUND from .messages import construct_event_message, construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @@ -596,47 +595,35 @@ async def handle_render_template( hass.loop.call_soon_threadsafe(info.async_refresh) +def _serialize_entity_sources( + entity_infos: dict[str, dict[str, str]] +) -> dict[str, Any]: + """Prepare a websocket response from a dict of entity sources.""" + result = {} + for entity_id, entity_info in entity_infos.items(): + result[entity_id] = {"domain": entity_info["domain"]} + return result + + @callback -@decorators.websocket_command( - {vol.Required("type"): "entity/source", vol.Optional("entity_id"): [cv.entity_id]} -) +@decorators.websocket_command({vol.Required("type"): "entity/source"}) def handle_entity_source( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle entity source command.""" - raw_sources = entity.entity_sources(hass) + all_entity_sources = entity.entity_sources(hass) entity_perm = connection.user.permissions.check_entity - if "entity_id" not in msg: - if connection.user.permissions.access_all_entities(POLICY_READ): - sources = raw_sources - else: - sources = { - entity_id: source - for entity_id, source in raw_sources.items() - if entity_perm(entity_id, POLICY_READ) - } + if connection.user.permissions.access_all_entities(POLICY_READ): + entity_sources = all_entity_sources + else: + entity_sources = { + entity_id: source + for entity_id, source in all_entity_sources.items() + if entity_perm(entity_id, POLICY_READ) + } - connection.send_result(msg["id"], sources) - return - - sources = {} - - for entity_id in msg["entity_id"]: - if not entity_perm(entity_id, POLICY_READ): - raise Unauthorized( - context=connection.context(msg), - permission=POLICY_READ, - perm_category=CAT_ENTITIES, - ) - - if (source := raw_sources.get(entity_id)) is None: - connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") - return - - sources[entity_id] = source - - connection.send_result(msg["id"], sources) + connection.send_result(msg["id"], _serialize_entity_sources(entity_sources)) @decorators.websocket_command( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index b1b2027c65d..8cd5e23ce29 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -20,7 +20,7 @@ from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAG from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.loader import async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_setup_component @@ -1941,76 +1941,10 @@ async def test_entity_source_admin( assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { - "test_domain.entity_1": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, + "test_domain.entity_1": {"domain": "test_platform"}, + "test_domain.entity_2": {"domain": "test_platform"}, } - # Fetch one - await websocket_client.send_json( - {"id": 7, "type": "entity/source", "entity_id": ["test_domain.entity_2"]} - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - } - - # Fetch two - await websocket_client.send_json( - { - "id": 8, - "type": "entity/source", - "entity_id": ["test_domain.entity_2", "test_domain.entity_1"], - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 8 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "test_domain.entity_1": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - } - - # Fetch non existing - await websocket_client.send_json( - { - "id": 9, - "type": "entity/source", - "entity_id": ["test_domain.entity_2", "test_domain.non_existing"], - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 9 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - # Mock policy hass_admin_user.groups = [] hass_admin_user.mock_policy( @@ -2025,24 +1959,9 @@ async def test_entity_source_admin( assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, + "test_domain.entity_2": {"domain": "test_platform"}, } - # Fetch unauthorized - await websocket_client.send_json( - {"id": 11, "type": "entity/source", "entity_id": ["test_domain.entity_1"]} - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 11 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_UNAUTHORIZED - async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: """Test subscribing to a trigger.""" From 1e2b0b65afc075d0e73cd4a75d3f58980a4ac52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 12 Sep 2023 15:58:25 +0200 Subject: [PATCH 430/984] Bump hass-nabucasa from 0.70.0 to 0.71.0 (#100193) Bump hass-nabucasa from 0.70.0 to 0.71.1 --- homeassistant/components/cloud/__init__.py | 2 -- homeassistant/components/cloud/const.py | 1 - homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_init.py | 1 - 7 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 40e5f264caf..4dc242376d9 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -47,7 +47,6 @@ from .const import ( CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_RELAYER_SERVER, - CONF_REMOTE_SNI_SERVER, CONF_REMOTESTATE_SERVER, CONF_SERVICEHANDLERS_SERVER, CONF_THINGTALK_SERVER, @@ -115,7 +114,6 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_ALEXA_SERVER): str, vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, - vol.Optional(CONF_REMOTE_SNI_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, vol.Optional(CONF_THINGTALK_SERVER): str, vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 7aa39efbf07..bd9d61cde16 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -55,7 +55,6 @@ CONF_ACME_SERVER = "acme_server" CONF_ALEXA_SERVER = "alexa_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" -CONF_REMOTE_SNI_SERVER = "remote_sni_server" CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_THINGTALK_SERVER = "thingtalk_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index a8e28d66291..fe0628f1886 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.70.0"] + "requirements": ["hass-nabucasa==0.71.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5aaf114f1b8..a5fb3856c05 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ cryptography==41.0.3 dbus-fast==2.6.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 home-assistant-frontend==20230911.0 diff --git a/requirements_all.txt b/requirements_all.txt index ce2842b6316..3c5ef0694b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -962,7 +962,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14831d4fa59..ec4ec481af6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -760,7 +760,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 # homeassistant.components.conversation hassil==1.2.5 diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 28b531b608c..e12775d5a4a 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -32,7 +32,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: "relayer_server": "test-relayer-server", "accounts_server": "test-acounts-server", "cloudhook_server": "test-cloudhook-server", - "remote_sni_server": "test-remote-sni-server", "alexa_server": "test-alexa-server", "acme_server": "test-acme-server", "remotestate_server": "test-remotestate-server", From 2b62285eeea5fce546337114066957b45895e6a7 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 12 Sep 2023 09:59:12 -0400 Subject: [PATCH 431/984] Fix addon slug validation (#100070) * Fix addon slug validation * Don't redefine compile --- homeassistant/components/hassio/__init__.py | 5 ++- tests/components/hassio/test_init.py | 35 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 72fb5ce5110..270309149ef 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -6,6 +6,7 @@ from contextlib import suppress from datetime import datetime, timedelta import logging import os +import re from typing import Any, NamedTuple import voluptuous as vol @@ -149,10 +150,12 @@ SERVICE_BACKUP_PARTIAL = "backup_partial" SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" +VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) + def valid_addon(value: Any) -> str: """Validate value is a valid addon slug.""" - value = cv.slug(value) + value = VALID_ADDON_SLUG(value) hass: HomeAssistant | None = None with suppress(HomeAssistantError): diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 31ee73013da..48f52ee7c24 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -633,6 +633,41 @@ async def test_invalid_service_calls( ) +async def test_addon_service_call_with_complex_slug( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Addon slugs can have ., - and _, confirm that passes validation.""" + supervisor_mock_data = { + "version_latest": "1.0.0", + "version": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test.a_1-2", + "slug": "test.a_1-2", + "state": "stopped", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "icon": False, + }, + ], + } + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.is_connected", + return_value=None, + ), patch( + "homeassistant.components.hassio.HassIO.get_supervisor_info", + return_value=supervisor_mock_data, + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + await hass.services.async_call("hassio", "addon_start", {"addon": "test.a_1-2"}) + + async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: From 198532d51d5d6a59cb7fddad329330aa75a5fcac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Tue, 12 Sep 2023 15:59:54 +0200 Subject: [PATCH 432/984] Airthings BLE unique id migration (#99832) * Fix sensor unique id * Add sensor identifiers * Migrate entities to new unique id * Fix linting issues * Fix crash when migrating entity fails * Change how entities are migrated * Remve debug logging * Remove unneeded async * Remove migration code from init file * Add migration code to sensor.py * Adjust for loops to improve speed * Bugfixes, improve documentation * Remove old comment * Remove unused function parameter * Address PR feedback * Add tests * Improve tests and test data * Refactor test * Update logger level Co-authored-by: J. Nick Koston * Adjust PR comments * Address more PR comments * Address PR comments and adjust tests * Fix PR comment --------- Co-authored-by: J. Nick Koston --- .../components/airthings_ble/sensor.py | 55 ++++- tests/components/airthings_ble/__init__.py | 108 ++++++++- tests/components/airthings_ble/test_sensor.py | 213 ++++++++++++++++++ 3 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 tests/components/airthings_ble/test_sensor.py diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 4783f3e3b35..b66d6b8f810 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -5,25 +5,35 @@ import logging from airthings_ble import AirthingsDevice -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, + Platform, UnitOfPressure, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + async_get as device_async_get, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import ( + RegistryEntry, + async_entries_for_device, + async_get as entity_async_get, +) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -107,9 +117,43 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { } +@callback +def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: + """Migrate entities to new unique ids (with BLE Address).""" + ent_reg = entity_async_get(hass) + unique_id_trailer = f"_{sensor_name}" + new_unique_id = f"{address}{unique_id_trailer}" + if ent_reg.async_get_entity_id(DOMAIN, Platform.SENSOR, new_unique_id): + # New unique id already exists + return + dev_reg = device_async_get(hass) + if not ( + device := dev_reg.async_get_device( + connections={(CONNECTION_BLUETOOTH, address)} + ) + ): + return + entities = async_entries_for_device( + ent_reg, + device_id=device.id, + include_disabled_entities=True, + ) + matching_reg_entry: RegistryEntry | None = None + for entry in entities: + if entry.unique_id.endswith(unique_id_trailer) and ( + not matching_reg_entry or "(" not in entry.unique_id + ): + matching_reg_entry = entry + if not matching_reg_entry: + return + entity_id = matching_reg_entry.entity_id + ent_reg.async_update_entity(entity_id=entity_id, new_unique_id=new_unique_id) + _LOGGER.debug("Migrated entity '%s' to unique id '%s'", entity_id, new_unique_id) + + async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Airthings BLE sensors.""" @@ -137,6 +181,7 @@ async def async_setup_entry( sensor_value, ) continue + async_migrate(hass, coordinator.data.address, sensor_type) entities.append( AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type]) ) @@ -165,7 +210,7 @@ class AirthingsSensor( if identifier := airthings_device.identifier: name += f" ({identifier})" - self._attr_unique_id = f"{name}_{entity_description.key}" + self._attr_unique_id = f"{airthings_device.address}_{entity_description.key}" self._attr_device_info = DeviceInfo( connections={ ( diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index 0dd78718a30..da0c312bf28 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -5,8 +5,11 @@ from unittest.mock import patch from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from tests.common import MockConfigEntry, MockEntity from tests.components.bluetooth import generate_advertisement_data, generate_ble_device @@ -36,18 +39,52 @@ def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None): ) +def patch_airthings_device_update(): + """Patch airthings-ble device.""" + return patch( + "homeassistant.components.airthings_ble.AirthingsBluetoothDeviceData.update_device", + return_value=WAVE_DEVICE_INFO, + ) + + WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( name="cc-cc-cc-cc-cc-cc", address="cc:cc:cc:cc:cc:cc", + device=generate_ble_device( + address="cc:cc:cc:cc:cc:cc", + name="Airthings Wave+", + ), rssi=-61, manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, - service_data={}, - service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + service_data={ + # Sensor data + "b42e2a68-ade7-11e4-89d3-123b93f75cba": bytearray( + b"\x01\x02\x03\x04\x00\x05\x00\x06\x00\x07\x00\x08\x00\x09\x00\x0A" + ), + # Manufacturer + "00002a29-0000-1000-8000-00805f9b34fb": bytearray(b"Airthings AS"), + # Model + "00002a24-0000-1000-8000-00805f9b34fb": bytearray(b"2930"), + # Identifier + "00002a25-0000-1000-8000-00805f9b34fb": bytearray(b"123456"), + # SW Version + "00002a26-0000-1000-8000-00805f9b34fb": bytearray(b"G-BLE-1.5.3-master+0"), + # HW Version + "00002a27-0000-1000-8000-00805f9b34fb": bytearray(b"REV A"), + # Command + "b42e2d06-ade7-11e4-89d3-123b93f75cba": bytearray(b"\x00"), + }, + service_uuids=[ + "b42e1c08-ade7-11e4-89d3-123b93f75cba", + "b42e2a68-ade7-11e4-89d3-123b93f75cba", + "00002a29-0000-1000-8000-00805f9b34fb", + "00002a24-0000-1000-8000-00805f9b34fb", + "00002a25-0000-1000-8000-00805f9b34fb", + "00002a26-0000-1000-8000-00805f9b34fb", + "00002a27-0000-1000-8000-00805f9b34fb", + "b42e2d06-ade7-11e4-89d3-123b93f75cba", + ], source="local", - device=generate_ble_device( - "cc:cc:cc:cc:cc:cc", - "cc-cc-cc-cc-cc-cc", - ), advertisement=generate_advertisement_data( manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], @@ -99,3 +136,62 @@ WAVE_DEVICE_INFO = AirthingsDevice( }, address="cc:cc:cc:cc:cc:cc", ) + +TEMPERATURE_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_temperature", + name="Airthings Wave Plus 123456 Temperature", +) + +HUMIDITY_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_humidity", + name="Airthings Wave Plus (123456) Humidity", +) + +CO2_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_co2", + name="Airthings Wave Plus 123456 CO2", +) + +CO2_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_co2", + name="Airthings Wave Plus (123456) CO2", +) + +VOC_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_voc", + name="Airthings Wave Plus 123456 CO2", +) + +VOC_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_voc", + name="Airthings Wave Plus (123456) VOC", +) + +VOC_V3 = MockEntity( + unique_id="cc:cc:cc:cc:cc:cc_voc", + name="Airthings Wave Plus (123456) VOC", +) + + +def create_entry(hass): + """Create a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WAVE_SERVICE_INFO.address, + title="Airthings Wave Plus (123456)", + ) + entry.add_to_hass(hass) + return entry + + +def create_device(hass, entry): + """Create a device for the given entry.""" + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, WAVE_SERVICE_INFO.address)}, + manufacturer="Airthings AS", + name="Airthings Wave Plus (123456)", + model="Wave Plus", + ) + return device diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py new file mode 100644 index 00000000000..68efd4d25f6 --- /dev/null +++ b/tests/components/airthings_ble/test_sensor.py @@ -0,0 +1,213 @@ +"""Test the Airthings Wave sensor.""" +import logging + +from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.components.airthings_ble import ( + CO2_V1, + CO2_V2, + HUMIDITY_V2, + TEMPERATURE_V1, + VOC_V1, + VOC_V2, + VOC_V3, + WAVE_DEVICE_INFO, + WAVE_SERVICE_INFO, + create_device, + create_entry, + patch_airthings_device_update, +) +from tests.components.bluetooth import inject_bluetooth_service_info + +_LOGGER = logging.getLogger(__name__) + + +async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): + """Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + sensor = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=TEMPERATURE_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert ( + entity_registry.async_get(sensor.entity_id).unique_id + == WAVE_DEVICE_INFO.address + "_temperature" + ) + + +async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): + """Verify that we can migrate from v2 (introduced in 2023.9.0) to the latest unique id format.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + sensor = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=HUMIDITY_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert ( + entity_registry.async_get(sensor.entity_id).unique_id + == WAVE_DEVICE_INFO.address + "_humidity" + ) + + +async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): + """Test if migration works when we have both v1 (pre 2023.9.0) and v2 (introduced in 2023.9.0) unique ids.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + v2 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=CO2_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v1 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=CO2_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert ( + entity_registry.async_get(v1.entity_id).unique_id + == WAVE_DEVICE_INFO.address + "_co2" + ) + assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id + + +async def test_migration_with_all_unique_ids(hass: HomeAssistant): + """Test if migration works when we have all unique ids.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + v1 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=VOC_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v2 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=VOC_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v3 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=VOC_V3.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert entity_registry.async_get(v1.entity_id).unique_id == v1.unique_id + assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id + assert entity_registry.async_get(v3.entity_id).unique_id == v3.unique_id From 9acca1bf5833cfa000e663f156f13411702c8114 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Sep 2023 16:01:15 +0200 Subject: [PATCH 433/984] Make modbus retry fast on read errors (#99576) * Fast retry on read errors. * Review comments. --- homeassistant/components/modbus/base_platform.py | 4 +++- homeassistant/components/modbus/sensor.py | 7 ++++++- tests/components/modbus/test_sensor.py | 12 ++++-------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 672250790da..0db716c3403 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -115,7 +115,9 @@ class BasePlatform(Entity): def async_run(self) -> None: """Remote start entity.""" self.async_hold(update=False) - self._cancel_call = async_call_later(self.hass, 1, self.async_update) + self._cancel_call = async_call_later( + self.hass, timedelta(milliseconds=100), self.async_update + ) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index fe2d4bc415d..f2ed504b41b 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 +from datetime import datetime, timedelta import logging from typing import Any @@ -19,6 +19,7 @@ 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, @@ -106,12 +107,16 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval + self._cancel_call = None raw_result = await self._hub.async_pb_call( 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 diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 551398c898b..98fd537f1bf 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -946,27 +946,23 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: ], ) @pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), + ("register_words", "do_exception"), [ ( [0x8000], True, - "17", - STATE_UNAVAILABLE, ), ], ) async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect + 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 == start_expect + assert hass.states.get(ENTITY_ID).state == "17" await do_next_cycle(hass, mock_do_cycle, 5) - 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 + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE @pytest.mark.parametrize( From c178388956e2c694f2beec8d0a94e3e8e02be05d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Sep 2023 16:05:59 +0200 Subject: [PATCH 434/984] Remove modbus pragma no cover and solve nan (#99221) * Remove pragma no cover. * Ruff ! * Review comments. * update test. * Review. * review. * Add slave test. --- .../components/modbus/base_platform.py | 25 ++-- tests/components/modbus/test_sensor.py | 119 +++++++++++++++++- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 0db716c3403..a3876bbe87c 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -190,10 +190,14 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers.reverse() return registers - def __process_raw_value(self, entry: float | int | str) -> float | int | str | None: + def __process_raw_value( + self, entry: float | int | str | bytes + ) -> float | int | str | bytes | None: """Process value from sensor with NaN handling, scaling, offset, min/max etc.""" if self._nan_value and entry in (self._nan_value, -self._nan_value): return None + if isinstance(entry, bytes): + return entry val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: return self._min_value @@ -234,14 +238,20 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): if isinstance(v_temp, int) and self._precision == 0: v_result.append(str(v_temp)) elif v_temp is None: - v_result.append("") # pragma: no cover + v_result.append("0") elif v_temp != v_temp: # noqa: PLR0124 # NaN float detection replace with None - v_result.append("nan") # pragma: no cover + v_result.append("0") else: v_result.append(f"{float(v_temp):.{self._precision}f}") return ",".join(map(str, v_result)) + # NaN float detection replace with None + if val[0] != val[0]: # noqa: PLR0124 + return None + if byte_string == b"nan\x00": + return None + # Apply scale, precision, limits to floats and ints val_result = self.__process_raw_value(val[0]) @@ -251,15 +261,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): if val_result is None: return None - # NaN float detection replace with None - if val_result != val_result: # noqa: PLR0124 - return None # pragma: no cover if isinstance(val_result, int) and self._precision == 0: return str(val_result) - if isinstance(val_result, str): - if val_result == "nan": - val_result = None # pragma: no cover - return val_result + if isinstance(val_result, bytes): + return val_result.decode() return f"{float(val_result):.{self._precision}f}" diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 98fd537f1bf..14bccbafac4 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,4 +1,6 @@ """The tests for the Modbus sensor component.""" +import struct + from freezegun.api import FrozenDateTimeFactory import pytest @@ -654,6 +656,21 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: @pytest.mark.parametrize( ("config_addon", "register_words", "do_exception", "expected"), [ + ( + { + CONF_SLAVE_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + 0x5102, + 0x0304, + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + False, + ["34899771392", "0"], + ), ( { CONF_SLAVE_COUNT: 0, @@ -930,6 +947,65 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_SCAN_INTERVAL: 1, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + ("config_addon", "register_words", "expected"), + [ + ( + { + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [0x6E61, 0x6E00], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_COUNT: 2, + CONF_STRUCTURE: "4s", + }, + [0x6E61, 0x6E00], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_COUNT: 2, + CONF_STRUCTURE: "4s", + }, + [0x6161, 0x6100], + "aaa\x00", + ), + ], +) +async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: + """Run test for sensor.""" + assert hass.states.get(ENTITY_ID).state == expected + + @pytest.mark.parametrize( "do_config", [ @@ -989,10 +1065,35 @@ async def test_lazy_error_sensor( CONF_DATA_TYPE: DataType.CUSTOM, CONF_STRUCTURE: ">4f", }, - # floats: 7.931250095367432, 10.600000381469727, + # floats: nan, 10.600000381469727, # 1.000879611487865e-28, 10.566553115844727 - [0x40FD, 0xCCCD, 0x4129, 0x999A, 0x10FD, 0xC0CD, 0x4129, 0x109A], - "7.93,10.60,0.00,10.57", + [ + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + 0x4129, + 0x999A, + 0x10FD, + 0xC0CD, + 0x4129, + 0x109A, + ], + "0,10.60,0.00,10.57", + ), + ( + { + CONF_COUNT: 4, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">2i", + CONF_NAN_VALUE: 0x0000000F, + }, + # int: nan, 10, + [ + 0x0000, + 0x000F, + 0x0000, + 0x000A, + ], + "0,10", ), ( { @@ -1012,6 +1113,18 @@ async def test_lazy_error_sensor( [0x0101], "257", ), + ( + { + CONF_COUNT: 8, + CONF_PRECISION: 2, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">4f", + }, + # floats: 7.931250095367432, 10.600000381469727, + # 1.000879611487865e-28, 10.566553115844727 + [0x40FD, 0xCCCD, 0x4129, 0x999A, 0x10FD, 0xC0CD, 0x4129, 0x109A], + "7.93,10.60,0.00,10.57", + ), ], ) async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: From d4952089955ddb707ae9130df36ab7a3161cf6da Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 16:19:26 +0200 Subject: [PATCH 435/984] Remove unnecessary block use of pylint disable in onvif (#100194) --- homeassistant/components/onvif/event.py | 3 +- homeassistant/components/onvif/parsers.py | 206 +++++++++++----------- 2 files changed, 100 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index bb42e63c52e..603957a230e 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -142,7 +142,6 @@ class EventManager: for update_callback in self._listeners: update_callback() - # pylint: disable=protected-access async def async_parse_messages(self, messages) -> None: """Parse notification message.""" unique_id = self.unique_id @@ -160,7 +159,7 @@ class EventManager: # # Our parser expects the topic to be # tns1:RuleEngine/CellMotionDetector/Motion - topic = msg.Topic._value_1.rstrip("/.") + topic = msg.Topic._value_1.rstrip("/.") # pylint: disable=protected-access if not (parser := PARSERS.get(topic)): if topic not in UNHANDLED_TOPICS: diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 8e6e3e25861..3f405767c54 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -42,21 +42,21 @@ def local_datetime_or_none(value: str) -> datetime.datetime | None: @PARSERS.register("tns1:VideoSource/MotionAlarm") -# pylint: disable=protected-access async def async_parse_motion_alarm(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/MotionAlarm """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -65,21 +65,21 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooBlurry/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooBlurry/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBlurry/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -89,21 +89,21 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooDark/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooDark/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_dark(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooDark/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -113,21 +113,21 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooBright/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooBright/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_bright(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBright/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -137,28 +137,27 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService") @PARSERS.register("tns1:VideoSource/GlobalSceneChange/ImagingService") @PARSERS.register("tns1:VideoSource/GlobalSceneChange/RecordingService") -# pylint: disable=protected-access async def async_parse_scene_change(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/GlobalSceneChange/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") -# pylint: disable=protected-access async def async_parse_detected_sound(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -168,7 +167,8 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: audio_source = "" audio_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -177,19 +177,18 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{audio_source}_{audio_analytics}_{rule}", + f"{uid}_{value_1}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") -# pylint: disable=protected-access async def async_parse_field_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -199,7 +198,8 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -208,12 +208,12 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = source.Value evt = Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -221,7 +221,6 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") -# pylint: disable=protected-access async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -231,7 +230,8 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -240,19 +240,18 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") -# pylint: disable=protected-access async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -262,7 +261,8 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -271,19 +271,18 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value in ["1", "true"], + value_1.Data.SimpleItem[0].Value in ["1", "true"], ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") -# pylint: disable=protected-access async def async_parse_tamper_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -293,7 +292,8 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -302,12 +302,12 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -315,7 +315,6 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") -# pylint: disable=protected-access async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -323,24 +322,24 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") -# pylint: disable=protected-access async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -348,24 +347,24 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect") -# pylint: disable=protected-access async def async_parse_person_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -373,24 +372,24 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") -# pylint: disable=protected-access async def async_parse_face_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -398,24 +397,24 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor") -# pylint: disable=protected-access async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -423,80 +422,81 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/Trigger/DigitalInput") -# pylint: disable=protected-access async def async_parse_digital_input(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/DigitalInput """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Digital Input", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/Trigger/Relay") -# pylint: disable=protected-access async def async_parse_relay(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/Relay """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Relay Triggered", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "active", + value_1.Data.SimpleItem[0].Value == "active", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") -# pylint: disable=protected-access async def async_parse_storage_failure(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/HardwareFailure/StorageFailure """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Storage Failure", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -504,19 +504,19 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/ProcessorUsage") -# pylint: disable=protected-access async def async_parse_processor_usage(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/ProcessorUsage """ try: - usage = float(msg.Message._value_1.Data.SimpleItem[0].Value) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + usage = float(value_1.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Processor Usage", "sensor", None, @@ -529,18 +529,16 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") -# pylint: disable=protected-access async def async_parse_last_reboot(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Reboot", "sensor", "timestamp", @@ -553,18 +551,16 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") -# pylint: disable=protected-access async def async_parse_last_reset(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Reset", "sensor", "timestamp", @@ -578,7 +574,6 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/Backup/Last") -# pylint: disable=protected-access async def async_parse_backup_last(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -586,11 +581,10 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Backup", "sensor", "timestamp", @@ -604,18 +598,16 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") -# pylint: disable=protected-access async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Clock Synchronization", "sensor", "timestamp", @@ -629,7 +621,6 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RecordingConfig/JobState") -# pylint: disable=protected-access async def async_parse_jobstate(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -637,14 +628,15 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Recording Job State", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "Active", + value_1.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -652,7 +644,6 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") -# pylint: disable=protected-access async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -662,7 +653,8 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -671,12 +663,12 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value, + value_1.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -684,7 +676,6 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") -# pylint: disable=protected-access async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -694,7 +685,8 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -703,12 +695,12 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value, + value_1.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): From 4e17901fefa82f683accdf4f27c96ea6a995cf44 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 16:37:35 +0200 Subject: [PATCH 436/984] Use shorthand attribute in Bloomsky (#100203) --- homeassistant/components/bloomsky/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 35c9a40a46a..4361af9ad37 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -100,6 +100,7 @@ class BloomSkySensor(SensorEntity): self._sensor_name = sensor_name self._attr_name = f"{device['DeviceName']} {sensor_name}" self._attr_unique_id = f"{self._device_id}-{sensor_name}" + self._attr_device_class = SENSOR_DEVICE_CLASS.get(sensor_name) self._attr_native_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get( sensor_name, None ) @@ -108,11 +109,6 @@ class BloomSkySensor(SensorEntity): sensor_name, None ) - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_DEVICE_CLASS.get(self._sensor_name) - def update(self) -> None: """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() From 085a584d98c5032dcb2adcf9507f1bb20233975d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 10:04:35 -0500 Subject: [PATCH 437/984] Use shorthand attributes in geniushub sensor (#100208) --- homeassistant/components/geniushub/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index 06237b6e8d5..22d95be079e 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -47,6 +47,9 @@ async def async_setup_platform( class GeniusBattery(GeniusDevice, SensorEntity): """Representation of a Genius Hub sensor.""" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + def __init__(self, broker, device, state_attr) -> None: """Initialize the sensor.""" super().__init__(broker, device) @@ -80,16 +83,6 @@ class GeniusBattery(GeniusDevice, SensorEntity): return icon - @property - def device_class(self) -> SensorDeviceClass: - """Return the device class of the sensor.""" - return SensorDeviceClass.BATTERY - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of the sensor.""" - return PERCENTAGE - @property def native_value(self) -> str: """Return the state of the sensor.""" From e2f7b3c6f8a928f61b4611f3d19359f64e65c5f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 10:05:15 -0500 Subject: [PATCH 438/984] Use shorthand attributes in buienradar camera (#100205) --- homeassistant/components/buienradar/camera.py | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 86e650aefed..439921928d6 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -58,6 +58,9 @@ class BuienradarCam(Camera): [0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata """ + _attr_entity_registry_enabled_default = False + _attr_name = "Buienradar" + def __init__( self, latitude: float, longitude: float, delta: float, country: str ) -> None: @@ -67,8 +70,6 @@ class BuienradarCam(Camera): """ super().__init__() - self._name = "Buienradar" - # dimension (x and y) of returned radar image self._dimension = DEFAULT_DIMENSION @@ -94,12 +95,7 @@ class BuienradarCam(Camera): # deadline for image refresh - self.delta after last successful load self._deadline: datetime | None = None - self._unique_id = f"{latitude:2.6f}{longitude:2.6f}" - - @property - def name(self) -> str: - """Return the component name.""" - return self._name + self._attr_unique_id = f"{latitude:2.6f}{longitude:2.6f}" def __needs_refresh(self) -> bool: if not (self._delta and self._deadline and self._last_image): @@ -187,13 +183,3 @@ class BuienradarCam(Camera): async with self._condition: self._loading = False self._condition.notify_all() - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def entity_registry_enabled_default(self) -> bool: - """Disable entity by default.""" - return False From 83ef5450e9d11dd299e767351147e0b5c620445f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 10:05:31 -0500 Subject: [PATCH 439/984] Use shorthand attributes in garadget cover (#100207) --- homeassistant/components/garadget/cover.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 826f21e9f88..6d9705cee75 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -92,6 +92,8 @@ def setup_platform( class GaradgetCover(CoverEntity): """Representation of a Garadget cover.""" + _attr_device_class = CoverDeviceClass.GARAGE + def __init__(self, hass, args): """Initialize the cover.""" self.particle_url = "https://api.particle.io" @@ -174,11 +176,6 @@ class GaradgetCover(CoverEntity): return None return self._state == STATE_CLOSED - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of this device, from component DEVICE_CLASSES.""" - return CoverDeviceClass.GARAGE - def get_token(self): """Get new token for usage during this session.""" args = { From 6e6680dc4daa89e752d6e8b88cf6a59e6924d582 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 17:12:22 +0200 Subject: [PATCH 440/984] Enable asyncio debug mode in tests (#100197) --- tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 99db0884496..f743a2fe96a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -318,6 +318,12 @@ def long_repr_strings() -> Generator[None, None, None]: arepr.maxother = original_maxother +@pytest.fixture(autouse=True) +def enable_event_loop_debug(event_loop: asyncio.AbstractEventLoop) -> None: + """Enable event loop debug mode.""" + event_loop.set_debug(True) + + @pytest.fixture(autouse=True) def verify_cleanup( event_loop: asyncio.AbstractEventLoop, From 54c034185f129ce375148bf2a02ef9cefdf1c016 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 17:13:13 +0200 Subject: [PATCH 441/984] Use shorthand attributes in Isy994 (#100209) --- homeassistant/components/isy994/binary_sensor.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 27f1887bd92..7be3b87a0d3 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -249,7 +249,8 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): ) -> None: """Initialize the ISY binary sensor device.""" super().__init__(node, device_info=device_info) - self._device_class = force_device_class + # This was discovered by parsing the device type code during init + self._attr_device_class = force_device_class @property def is_on(self) -> bool | None: @@ -258,14 +259,6 @@ class ISYBinarySensorEntity(ISYNodeEntity, BinarySensorEntity): return None return bool(self._node.status) - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device. - - This was discovered by parsing the device type code during init - """ - return self._device_class - class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity): """Representation of an ISY Insteon binary sensor device. From 75951dd67be7c2e149348c6171a9af5b07d4de8a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 17:15:36 +0200 Subject: [PATCH 442/984] Use shorthand attributes in Point (#100214) --- homeassistant/components/point/__init__.py | 52 +++++-------------- .../components/point/binary_sensor.py | 18 ++----- homeassistant/components/point/sensor.py | 13 ++--- 3 files changed, 22 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 2030483d9cd..130ea116cc1 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -264,9 +264,20 @@ class MinutPointEntity(Entity): self._client = point_client self._id = device_id self._name = self.device.name - self._device_class = device_class + self._attr_device_class = device_class self._updated = utc_from_timestamp(0) - self._value = None + self._attr_unique_id = f"point.{device_id}-{device_class}" + device = self.device.device + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, + identifiers={(DOMAIN, device["device_id"])}, + manufacturer="Minut", + model=f"Point v{device['hardware_version']}", + name=device["description"], + sw_version=device["firmware"]["installed"], + via_device=(DOMAIN, device["home"]), + ) + self._attr_name = f"{self._name} {device_class.capitalize()}" def __str__(self): """Return string representation of device.""" @@ -298,11 +309,6 @@ class MinutPointEntity(Entity): """Return the representation of the device.""" return self._client.device(self.device_id) - @property - def device_class(self): - """Return the device class.""" - return self._device_class - @property def device_id(self): """Return the id of the device.""" @@ -317,25 +323,6 @@ class MinutPointEntity(Entity): ) return attrs - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - device = self.device.device - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, - identifiers={(DOMAIN, device["device_id"])}, - manufacturer="Minut", - model=f"Point v{device['hardware_version']}", - name=device["description"], - sw_version=device["firmware"]["installed"], - via_device=(DOMAIN, device["home"]), - ) - - @property - def name(self): - """Return the display name of this device.""" - return f"{self._name} {self.device_class.capitalize()}" - @property def is_updated(self): """Return true if sensor have been updated.""" @@ -344,15 +331,4 @@ class MinutPointEntity(Entity): @property def last_update(self): """Return the last_update time for the device.""" - last_update = parse_datetime(self.device.last_update) - return last_update - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"point.{self._id}-{self.device_class}" - - @property - def value(self): - """Return the sensor value.""" - return self._value + return parse_datetime(self.device.last_update) diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index e8db51fd0fc..81101d2da79 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -76,6 +76,9 @@ class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): self._device_name = device_name self._async_unsub_hook_dispatcher_connect = None self._events = EVENTS[device_name] + self._attr_unique_id = f"point.{device_id}-{device_name}" + self._attr_icon = DEVICES[self._device_name].get("icon") + self._attr_name = f"{self._name} {device_name.capitalize()}" async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" @@ -124,18 +127,3 @@ class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): else: self._attr_is_on = _is_on self.async_write_ha_state() - - @property - def name(self): - """Return the display name of this device.""" - return f"{self._name} {self._device_name.capitalize()}" - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEVICES[self._device_name].get("icon") - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"point.{self._id}-{self._device_name}" diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 34571c801a6..462d8270f0a 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -98,13 +98,10 @@ class MinutPointSensor(MinutPointEntity, SensorEntity): """Update the value of the sensor.""" _LOGGER.debug("Update sensor value for %s", self) if self.is_updated: - self._value = await self.device.sensor(self.device_class) + self._attr_native_value = await self.device.sensor(self.device_class) + if self.native_value is not None: + self._attr_native_value = round( + self.native_value, self.entity_description.precision + ) self._updated = parse_datetime(self.device.last_update) self.async_write_ha_state() - - @property - def native_value(self): - """Return the state of the sensor.""" - if self.value is None: - return None - return round(self.value, self.entity_description.precision) From 6485320bc44ea7a4d819b4a31bc2ed8df572dacf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 17:31:25 +0200 Subject: [PATCH 443/984] Improve type annotations in websocket_api tests (#100198) --- .../components/websocket_api/test_commands.py | 130 +++++++++++++----- 1 file changed, 95 insertions(+), 35 deletions(-) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 8cd5e23ce29..f200c44acca 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -92,7 +92,9 @@ def _apply_entities_changes(state_dict: dict, change_dict: dict) -> None: del state_dict[STATE_KEY_LONG_NAMES[key]][item] -async def test_fire_event(hass: HomeAssistant, websocket_client) -> None: +async def test_fire_event( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test fire event command.""" runs = [] @@ -121,7 +123,9 @@ async def test_fire_event(hass: HomeAssistant, websocket_client) -> None: assert runs[0].data == {"hello": "world"} -async def test_fire_event_without_data(hass: HomeAssistant, websocket_client) -> None: +async def test_fire_event_without_data( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test fire event command.""" runs = [] @@ -149,7 +153,9 @@ async def test_fire_event_without_data(hass: HomeAssistant, websocket_client) -> assert runs[0].data == {} -async def test_call_service(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") @@ -179,7 +185,7 @@ async def test_call_service(hass: HomeAssistant, websocket_client) -> None: @pytest.mark.parametrize("command", ("call_service", "call_service_action")) async def test_call_service_blocking( - hass: HomeAssistant, websocket_client, command + hass: HomeAssistant, websocket_client: MockHAClientWebSocket, command ) -> None: """Test call service commands block, except for homeassistant restart / stop.""" with patch( @@ -256,7 +262,9 @@ async def test_call_service_blocking( ) -async def test_call_service_target(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_target( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command with target.""" calls = async_mock_service(hass, "domain_test", "test_service") @@ -316,7 +324,9 @@ async def test_call_service_target_template( assert msg["error"]["code"] == const.ERR_INVALID_FORMAT -async def test_call_service_not_found(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_not_found( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command.""" await websocket_client.send_json( { @@ -433,7 +443,9 @@ async def test_call_service_schema_validation_error( assert len(calls) == 0 -async def test_call_service_error(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_error( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command with error.""" @callback @@ -526,7 +538,9 @@ async def test_subscribe_unsubscribe_events( assert sum(hass.bus.async_listeners().values()) == init_count -async def test_get_states(hass: HomeAssistant, websocket_client) -> None: +async def test_get_states( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_states command.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bye", "universe") @@ -545,7 +559,9 @@ async def test_get_states(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == states -async def test_get_services(hass: HomeAssistant, websocket_client) -> None: +async def test_get_services( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_services command.""" for id_ in (5, 6): await websocket_client.send_json({"id": id_, "type": "get_services"}) @@ -557,7 +573,9 @@ async def test_get_services(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == hass.services.async_services() -async def test_get_config(hass: HomeAssistant, websocket_client) -> None: +async def test_get_config( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_config command.""" await websocket_client.send_json({"id": 5, "type": "get_config"}) @@ -584,7 +602,7 @@ async def test_get_config(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == hass.config.as_dict() -async def test_ping(websocket_client) -> None: +async def test_ping(websocket_client: MockHAClientWebSocket) -> None: """Test get_panels command.""" await websocket_client.send_json({"id": 5, "type": "ping"}) @@ -637,7 +655,7 @@ async def test_call_service_context_with_user( async def test_subscribe_requires_admin( - websocket_client, hass_admin_user: MockUser + websocket_client: MockHAClientWebSocket, hass_admin_user: MockUser ) -> None: """Test subscribing events without being admin.""" hass_admin_user.groups = [] @@ -668,7 +686,9 @@ async def test_states_filters_visible( assert msg["result"][0]["entity_id"] == "test.entity" -async def test_get_states_not_allows_nan(hass: HomeAssistant, websocket_client) -> None: +async def test_get_states_not_allows_nan( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_states command converts NaN to None.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) @@ -691,7 +711,9 @@ async def test_get_states_not_allows_nan(hass: HomeAssistant, websocket_client) async def test_subscribe_unsubscribe_events_whitelist( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe events on whitelist.""" hass_admin_user.groups = [] @@ -728,7 +750,9 @@ async def test_subscribe_unsubscribe_events_whitelist( async def test_subscribe_unsubscribe_events_state_changed( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe state_changed events.""" hass_admin_user.groups = [] @@ -754,7 +778,9 @@ async def test_subscribe_unsubscribe_events_state_changed( async def test_subscribe_entities_with_unserializable_state( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe entities with an unserializeable state.""" @@ -871,7 +897,9 @@ async def test_subscribe_entities_with_unserializable_state( async def test_subscribe_unsubscribe_entities( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe entities.""" @@ -1037,7 +1065,9 @@ async def test_subscribe_unsubscribe_entities( async def test_subscribe_unsubscribe_entities_specific_entities( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe entities with a list of entity ids.""" @@ -1376,7 +1406,7 @@ async def test_render_template_with_error( ) async def test_render_template_with_timeout_and_error( hass: HomeAssistant, - websocket_client, + websocket_client: MockHAClientWebSocket, caplog: pytest.LogCaptureFixture, template: str, expected_events: list[dict[str, str]], @@ -1592,7 +1622,7 @@ async def test_render_template_strict_with_timeout_and_error_2( ) async def test_render_template_error_in_template_code( hass: HomeAssistant, - websocket_client, + websocket_client: MockHAClientWebSocket, caplog: pytest.LogCaptureFixture, template: str, expected_events_1: list[dict[str, str]], @@ -1691,7 +1721,9 @@ async def test_render_template_error_in_template_code_2( async def test_render_template_with_delayed_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a template with an error that only happens after a state change. @@ -1815,7 +1847,9 @@ async def test_render_template_with_delayed_error_2( async def test_render_template_with_timeout( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a template that will timeout.""" @@ -1859,7 +1893,9 @@ async def test_render_template_returns_with_match_all( assert msg["success"] -async def test_manifest_list(hass: HomeAssistant, websocket_client) -> None: +async def test_manifest_list( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test loading manifests.""" http = await async_get_integration(hass, "http") websocket_api = await async_get_integration(hass, "websocket_api") @@ -1897,7 +1933,9 @@ async def test_manifest_list_specific_integrations( ] -async def test_manifest_get(hass: HomeAssistant, websocket_client) -> None: +async def test_manifest_get( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test getting a manifest.""" hue = await async_get_integration(hass, "hue") @@ -1924,7 +1962,9 @@ async def test_manifest_get(hass: HomeAssistant, websocket_client) -> None: async def test_entity_source_admin( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Check that we fetch sources correctly.""" platform = MockEntityPlatform(hass) @@ -1963,7 +2003,9 @@ async def test_entity_source_admin( } -async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: +async def test_subscribe_trigger( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test subscribing to a trigger.""" init_count = sum(hass.bus.async_listeners().values()) @@ -2017,7 +2059,9 @@ async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: assert sum(hass.bus.async_listeners().values()) == init_count -async def test_test_condition(hass: HomeAssistant, websocket_client) -> None: +async def test_test_condition( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test testing a condition.""" hass.states.async_set("hello.world", "paulus") @@ -2077,7 +2121,9 @@ async def test_test_condition(hass: HomeAssistant, websocket_client) -> None: assert msg["result"]["result"] is False -async def test_execute_script(hass: HomeAssistant, websocket_client) -> None: +async def test_execute_script( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test testing a condition.""" calls = async_mock_service( hass, "domain_test", "test_service", response={"hello": "world"} @@ -2226,7 +2272,9 @@ async def test_execute_script_with_dynamically_validated_action( async def test_subscribe_unsubscribe_bootstrap_integrations( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" await websocket_client.send_json( @@ -2248,7 +2296,9 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( async def test_integration_setup_info( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" hass.data[DATA_SETUP_TIME] = { @@ -2284,7 +2334,9 @@ async def test_integration_setup_info( ("action", [{"service": "domain_test.test_service"}]), ), ) -async def test_validate_config_works(websocket_client, key, config) -> None: +async def test_validate_config_works( + websocket_client: MockHAClientWebSocket, key, config +) -> None: """Test config validation.""" await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) @@ -2323,7 +2375,9 @@ async def test_validate_config_works(websocket_client, key, config) -> None: ), ), ) -async def test_validate_config_invalid(websocket_client, key, config, error) -> None: +async def test_validate_config_invalid( + websocket_client: MockHAClientWebSocket, key, config, error +) -> None: """Test config validation.""" await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) @@ -2335,7 +2389,9 @@ async def test_validate_config_invalid(websocket_client, key, config, error) -> async def test_message_coalescing( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test enabling message coalescing.""" await websocket_client.send_json( @@ -2407,7 +2463,9 @@ async def test_message_coalescing( async def test_message_coalescing_not_supported_by_websocket_client( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test enabling message coalescing not supported by websocket client.""" await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) @@ -2449,7 +2507,9 @@ async def test_message_coalescing_not_supported_by_websocket_client( async def test_client_message_coalescing( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test client message coalescing.""" await websocket_client.send_json( From 6545fba5499230918dfe6e12c003cc7f082ace49 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 17:34:41 +0200 Subject: [PATCH 444/984] Use shorthand attributes in Universal (#100219) --- homeassistant/components/universal/media_player.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index c221a10284a..00f345fd248 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -40,7 +40,6 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, - MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -177,7 +176,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): self._child_state = None self._state_template_result = None self._state_template = config.get(CONF_STATE_TEMPLATE) - self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_unique_id = config.get(CONF_UNIQUE_ID) self._browse_media_entity = config.get(CONF_BROWSE_MEDIA_ENTITY) @@ -294,11 +293,6 @@ class UniversalMediaPlayer(MediaPlayerEntity): DOMAIN, service_name, service_data, blocking=True, context=self._context ) - @property - def device_class(self) -> MediaPlayerDeviceClass | None: - """Return the class of this device.""" - return self._device_class - @property def master_state(self): """Return the master state for entity or None.""" From 4e202eb3767622bdfd2995955c25cf6c3edfc72b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 17:35:01 +0200 Subject: [PATCH 445/984] Use shorthand attributes in Yamaha Musiccast (#100220) --- .../components/yamaha_musiccast/__init__.py | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index c3851074365..9e8b8fed530 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -136,24 +136,9 @@ class MusicCastEntity(CoordinatorEntity[MusicCastDataUpdateCoordinator]): ) -> None: """Initialize the MusicCast entity.""" super().__init__(coordinator) - self._enabled_default = enabled_default - self._icon = icon - self._name = name - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default + self._attr_entity_registry_enabled_default = enabled_default + self._attr_icon = icon + self._attr_name = name class MusicCastDeviceEntity(MusicCastEntity): From 71c4f675e0eb15944fa00b05495b157a6a2cf1aa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 18:01:05 +0200 Subject: [PATCH 446/984] Use shorthand attributes in SPC (#100217) --- homeassistant/components/spc/alarm_control_panel.py | 6 +----- homeassistant/components/spc/binary_sensor.py | 12 ++---------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index b78703666bc..ace352b2ba0 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -64,6 +64,7 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): """Initialize the SPC alarm panel.""" self._area = area self._api = api + self._attr_name = area.name async def async_added_to_hass(self) -> None: """Call for adding new entities.""" @@ -80,11 +81,6 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): """Call update method.""" self.async_schedule_update_ha_state(True) - @property - def name(self) -> str: - """Return the name of the device.""" - return self._area.name - @property def changed_by(self) -> str: """Return the user the last change was triggered by.""" diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py index c4aaefdd518..a43551567e6 100644 --- a/homeassistant/components/spc/binary_sensor.py +++ b/homeassistant/components/spc/binary_sensor.py @@ -53,6 +53,8 @@ class SpcBinarySensor(BinarySensorEntity): def __init__(self, zone: Zone) -> None: """Initialize the sensor device.""" self._zone = zone + self._attr_name = zone.name + self._attr_device_class = _get_device_class(zone.type) async def async_added_to_hass(self) -> None: """Call for adding new entities.""" @@ -69,17 +71,7 @@ class SpcBinarySensor(BinarySensorEntity): """Call update method.""" self.async_schedule_update_ha_state(True) - @property - def name(self) -> str: - """Return the name of the device.""" - return self._zone.name - @property def is_on(self) -> bool: """Whether the device is switched on.""" return self._zone.input == ZoneInput.OPEN - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the device class.""" - return _get_device_class(self._zone.type) From 86bccf769ec1052c7ae169c33ed4f0ef35a5c2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Klomp?= Date: Tue, 12 Sep 2023 18:30:55 +0200 Subject: [PATCH 447/984] Add Entity Descriptions to SMA integration (#58707) Co-authored-by: J. Nick Koston --- homeassistant/components/sma/sensor.py | 813 ++++++++++++++++++++++++- 1 file changed, 783 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index dbcc1931e58..11ed720b51c 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -8,10 +8,22 @@ import pysma from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.const import ( + PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, + EntityCategory, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,6 +35,762 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN, PYSMA_COORDINATOR, PYSMA_DEVICE_INFO, PYSMA_SENSORS +SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { + "status": SensorEntityDescription( + key="status", + name="Status", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "operating_status_general": SensorEntityDescription( + key="operating_status_general", + name="Operating Status General", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "inverter_condition": SensorEntityDescription( + key="inverter_condition", + name="Inverter Condition", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "inverter_system_init": SensorEntityDescription( + key="inverter_system_init", + name="Inverter System Init", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "grid_connection_status": SensorEntityDescription( + key="grid_connection_status", + name="Grid Connection Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "grid_relay_status": SensorEntityDescription( + key="grid_relay_status", + name="Grid Relay Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "pv_power_a": SensorEntityDescription( + key="pv_power_a", + name="PV Power A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "pv_power_b": SensorEntityDescription( + key="pv_power_b", + name="PV Power B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "pv_power_c": SensorEntityDescription( + key="pv_power_c", + name="PV Power C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "pv_voltage_a": SensorEntityDescription( + key="pv_voltage_a", + name="PV Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_voltage_b": SensorEntityDescription( + key="pv_voltage_b", + name="PV Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_voltage_c": SensorEntityDescription( + key="pv_voltage_c", + name="PV Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_current_a": SensorEntityDescription( + key="pv_current_a", + name="PV Current A", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "pv_current_b": SensorEntityDescription( + key="pv_current_b", + name="PV Current B", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "pv_current_c": SensorEntityDescription( + key="pv_current_c", + name="PV Current C", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "insulation_residual_current": SensorEntityDescription( + key="insulation_residual_current", + name="Insulation Residual Current", + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "grid_power": SensorEntityDescription( + key="grid_power", + name="Grid Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "frequency": SensorEntityDescription( + key="frequency", + name="Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + entity_registry_enabled_default=False, + ), + "power_l1": SensorEntityDescription( + key="power_l1", + name="Power L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "power_l2": SensorEntityDescription( + key="power_l2", + name="Power L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "power_l3": SensorEntityDescription( + key="power_l3", + name="Power L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power": SensorEntityDescription( + key="grid_reactive_power", + name="Grid Reactive Power", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l1": SensorEntityDescription( + key="grid_reactive_power_l1", + name="Grid Reactive Power L1", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l2": SensorEntityDescription( + key="grid_reactive_power_l2", + name="Grid Reactive Power L2", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l3": SensorEntityDescription( + key="grid_reactive_power_l3", + name="Grid Reactive Power L3", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power": SensorEntityDescription( + key="grid_apparent_power", + name="Grid Apparent Power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l1": SensorEntityDescription( + key="grid_apparent_power_l1", + name="Grid Apparent Power L1", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l2": SensorEntityDescription( + key="grid_apparent_power_l2", + name="Grid Apparent Power L2", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l3": SensorEntityDescription( + key="grid_apparent_power_l3", + name="Grid Apparent Power L3", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_power_factor": SensorEntityDescription( + key="grid_power_factor", + name="Grid Power Factor", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + entity_registry_enabled_default=False, + ), + "grid_power_factor_excitation": SensorEntityDescription( + key="grid_power_factor_excitation", + name="Grid Power Factor Excitation", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "current_l1": SensorEntityDescription( + key="current_l1", + name="Current L1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_l2": SensorEntityDescription( + key="current_l2", + name="Current L2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_l3": SensorEntityDescription( + key="current_l3", + name="Current L3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_total": SensorEntityDescription( + key="current_total", + name="Current Total", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "voltage_l1": SensorEntityDescription( + key="voltage_l1", + name="Voltage L1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "voltage_l2": SensorEntityDescription( + key="voltage_l2", + name="Voltage L2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "voltage_l3": SensorEntityDescription( + key="voltage_l3", + name="Voltage L3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "total_yield": SensorEntityDescription( + key="total_yield", + name="Total Yield", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "daily_yield": SensorEntityDescription( + key="daily_yield", + name="Daily Yield", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_power_supplied": SensorEntityDescription( + key="metering_power_supplied", + name="Metering Power Supplied", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_power_absorbed": SensorEntityDescription( + key="metering_power_absorbed", + name="Metering Power Absorbed", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_frequency": SensorEntityDescription( + key="metering_frequency", + name="Metering Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + ), + "metering_total_yield": SensorEntityDescription( + key="metering_total_yield", + name="Metering Total Yield", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_total_absorbed": SensorEntityDescription( + key="metering_total_absorbed", + name="Metering Total Absorbed", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_current_l1": SensorEntityDescription( + key="metering_current_l1", + name="Metering Current L1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_current_l2": SensorEntityDescription( + key="metering_current_l2", + name="Metering Current L2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_current_l3": SensorEntityDescription( + key="metering_current_l3", + name="Metering Current L3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_voltage_l1": SensorEntityDescription( + key="metering_voltage_l1", + name="Metering Voltage L1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_voltage_l2": SensorEntityDescription( + key="metering_voltage_l2", + name="Metering Voltage L2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_voltage_l3": SensorEntityDescription( + key="metering_voltage_l3", + name="Metering Voltage L3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_active_power_feed_l1": SensorEntityDescription( + key="metering_active_power_feed_l1", + name="Metering Active Power Feed L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_feed_l2": SensorEntityDescription( + key="metering_active_power_feed_l2", + name="Metering Active Power Feed L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_feed_l3": SensorEntityDescription( + key="metering_active_power_feed_l3", + name="Metering Active Power Feed L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l1": SensorEntityDescription( + key="metering_active_power_draw_l1", + name="Metering Active Power Draw L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l2": SensorEntityDescription( + key="metering_active_power_draw_l2", + name="Metering Active Power Draw L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l3": SensorEntityDescription( + key="metering_active_power_draw_l3", + name="Metering Active Power Draw L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_current_consumption": SensorEntityDescription( + key="metering_current_consumption", + name="Metering Current Consumption", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "metering_total_consumption": SensorEntityDescription( + key="metering_total_consumption", + name="Metering Total Consumption", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "pv_gen_meter": SensorEntityDescription( + key="pv_gen_meter", + name="PV Gen Meter", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "optimizer_power": SensorEntityDescription( + key="optimizer_power", + name="Optimizer Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "optimizer_current": SensorEntityDescription( + key="optimizer_current", + name="Optimizer Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "optimizer_voltage": SensorEntityDescription( + key="optimizer_voltage", + name="Optimizer Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "optimizer_temp": SensorEntityDescription( + key="optimizer_temp", + name="Optimizer Temp", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + ), + "battery_soc_total": SensorEntityDescription( + key="battery_soc_total", + name="Battery SOC Total", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + ), + "battery_soc_a": SensorEntityDescription( + key="battery_soc_a", + name="Battery SOC A", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_soc_b": SensorEntityDescription( + key="battery_soc_b", + name="Battery SOC B", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_soc_c": SensorEntityDescription( + key="battery_soc_c", + name="Battery SOC C", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_voltage_a": SensorEntityDescription( + key="battery_voltage_a", + name="Battery Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_voltage_b": SensorEntityDescription( + key="battery_voltage_b", + name="Battery Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_voltage_c": SensorEntityDescription( + key="battery_voltage_c", + name="Battery Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_current_a": SensorEntityDescription( + key="battery_current_a", + name="Battery Current A", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_current_b": SensorEntityDescription( + key="battery_current_b", + name="Battery Current B", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_current_c": SensorEntityDescription( + key="battery_current_c", + name="Battery Current C", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_temp_a": SensorEntityDescription( + key="battery_temp_a", + name="Battery Temp A", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_temp_b": SensorEntityDescription( + key="battery_temp_b", + name="Battery Temp B", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_temp_c": SensorEntityDescription( + key="battery_temp_c", + name="Battery Temp C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_status_operating_mode": SensorEntityDescription( + key="battery_status_operating_mode", + name="Battery Status Operating Mode", + ), + "battery_capacity_total": SensorEntityDescription( + key="battery_capacity_total", + name="Battery Capacity Total", + native_unit_of_measurement=PERCENTAGE, + ), + "battery_capacity_a": SensorEntityDescription( + key="battery_capacity_a", + name="Battery Capacity A", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_capacity_b": SensorEntityDescription( + key="battery_capacity_b", + name="Battery Capacity B", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_capacity_c": SensorEntityDescription( + key="battery_capacity_c", + name="Battery Capacity C", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_a": SensorEntityDescription( + key="battery_charging_voltage_a", + name="Battery Charging Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_b": SensorEntityDescription( + key="battery_charging_voltage_b", + name="Battery Charging Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_c": SensorEntityDescription( + key="battery_charging_voltage_c", + name="Battery Charging Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_power_charge_total": SensorEntityDescription( + key="battery_power_charge_total", + name="Battery Power Charge Total", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "battery_power_charge_a": SensorEntityDescription( + key="battery_power_charge_a", + name="Battery Power Charge A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_charge_b": SensorEntityDescription( + key="battery_power_charge_b", + name="Battery Power Charge B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_charge_c": SensorEntityDescription( + key="battery_power_charge_c", + name="Battery Power Charge C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_charge_total": SensorEntityDescription( + key="battery_charge_total", + name="Battery Charge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "battery_charge_a": SensorEntityDescription( + key="battery_charge_a", + name="Battery Charge A", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_charge_b": SensorEntityDescription( + key="battery_charge_b", + name="Battery Charge B", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_charge_c": SensorEntityDescription( + key="battery_charge_c", + name="Battery Charge C", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_total": SensorEntityDescription( + key="battery_power_discharge_total", + name="Battery Power Discharge Total", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "battery_power_discharge_a": SensorEntityDescription( + key="battery_power_discharge_a", + name="Battery Power Discharge A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_b": SensorEntityDescription( + key="battery_power_discharge_b", + name="Battery Power Discharge B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_c": SensorEntityDescription( + key="battery_power_discharge_c", + name="Battery Power Discharge C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_discharge_total": SensorEntityDescription( + key="battery_discharge_total", + name="Battery Discharge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "battery_discharge_a": SensorEntityDescription( + key="battery_discharge_a", + name="Battery Discharge A", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_discharge_b": SensorEntityDescription( + key="battery_discharge_b", + name="Battery Discharge B", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_discharge_c": SensorEntityDescription( + key="battery_discharge_c", + name="Battery Discharge C", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "inverter_power_limit": SensorEntityDescription( + key="inverter_power_limit", + name="Inverter Power Limit", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -45,6 +813,7 @@ async def async_setup_entry( SMAsensor( coordinator, config_entry.unique_id, + SENSOR_ENTITIES.get(sensor.name), device_info, sensor, ) @@ -60,22 +829,23 @@ class SMAsensor(CoordinatorEntity, SensorEntity): self, coordinator: DataUpdateCoordinator, config_entry_unique_id: str, + description: SensorEntityDescription | None, device_info: DeviceInfo, pysma_sensor: pysma.sensor.Sensor, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._sensor = pysma_sensor - self._enabled_default = self._sensor.enabled - self._config_entry_unique_id = config_entry_unique_id - self._attr_device_info = device_info + if description is not None: + self.entity_description = description + else: + self._attr_name = pysma_sensor.name - if self.native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR: - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._attr_device_class = SensorDeviceClass.ENERGY - if self.native_unit_of_measurement == UnitOfPower.WATT: - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_device_class = SensorDeviceClass.POWER + self._sensor = pysma_sensor + + self._attr_device_info = device_info + self._attr_unique_id = ( + f"{config_entry_unique_id}-{pysma_sensor.key}_{pysma_sensor.key_idx}" + ) # Set sensor enabled to False. # Will be enabled by async_added_to_hass if actually used. @@ -83,36 +853,19 @@ class SMAsensor(CoordinatorEntity, SensorEntity): @property def name(self) -> str: - """Return the name of the sensor.""" + """Return the name of the sensor prefixed with the device name.""" if self._attr_device_info is None or not ( name_prefix := self._attr_device_info.get("name") ): name_prefix = "SMA" - return f"{name_prefix} {self._sensor.name}" + return f"{name_prefix} {super().name}" @property def native_value(self) -> StateType: """Return the state of the sensor.""" return self._sensor.value - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._sensor.unit - - @property - def unique_id(self) -> str: - """Return a unique identifier for this sensor.""" - return ( - f"{self._config_entry_unique_id}-{self._sensor.key}_{self._sensor.key_idx}" - ) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() From e84a4661b0eebc5ecff9bc25ae3ee9c229fe606c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 18:54:32 +0200 Subject: [PATCH 448/984] Add intial property to imap_content event data (#100171) * Add initial property to imap event data * Simplify loop Co-authored-by: Joost Lekkerkerker * MyPy --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/imap/coordinator.py | 48 ++++++++++++++------ tests/components/imap/const.py | 1 + tests/components/imap/test_init.py | 3 +- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 72be5e9bcf0..59c24b11e51 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -110,6 +110,15 @@ class ImapMessage: header_base[key] += header_instances # type: ignore[assignment] return header_base + @property + def message_id(self) -> str | None: + """Get the message ID.""" + value: str + for header, value in self.email_message.items(): + if header == "Message-ID": + return value + return None + @property def date(self) -> datetime | None: """Get the date the email was sent.""" @@ -189,6 +198,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Initiate imap client.""" self.imap_client = imap_client self.auth_errors: int = 0 + self._last_message_uid: str | None = None self._last_message_id: str | None = None self.custom_event_template = None _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE) @@ -209,16 +219,22 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) - async def _async_process_event(self, last_message_id: str) -> None: + async def _async_process_event(self, last_message_uid: str) -> None: """Send a event for the last message if the last message was changed.""" - response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]") + response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]") if response.result == "OK": message = ImapMessage(response.lines[1]) + # Set `initial` to `False` if the last message is triggered again + initial: bool = True + if (message_id := message.message_id) == self._last_message_id: + initial = False + self._last_message_id = message_id data = { "server": self.config_entry.data[CONF_SERVER], "username": self.config_entry.data[CONF_USERNAME], "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], + "initial": initial, "date": message.date, "text": message.text, "sender": message.sender, @@ -231,18 +247,20 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): data, parse_result=True ) _LOGGER.debug( - "imap custom template (%s) for msgid %s rendered to: %s", + "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", self.custom_event_template, - last_message_id, + last_message_uid, + message_id, data["custom"], + initial, ) except TemplateError as err: data["custom"] = None _LOGGER.error( - "Error rendering imap custom template (%s) for msgid %s " + "Error rendering IMAP custom template (%s) for msguid %s " "failed with message: %s", self.custom_event_template, - last_message_id, + last_message_uid, err, ) data["text"] = message.text[ @@ -263,10 +281,12 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): self.hass.bus.fire(EVENT_IMAP, data) _LOGGER.debug( - "Message with id %s processed, sender: %s, subject: %s", - last_message_id, + "Message with id %s (%s) processed, sender: %s, subject: %s, initial: %s", + last_message_uid, + message_id, message.sender, message.subject, + initial, ) async def _async_fetch_number_of_messages(self) -> int | None: @@ -282,20 +302,20 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) if not (count := len(message_ids := lines[0].split())): - self._last_message_id = None + self._last_message_uid = None return 0 - last_message_id = ( + last_message_uid = ( str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) if count else None ) if ( count - and last_message_id is not None - and self._last_message_id != last_message_id + and last_message_uid is not None + and self._last_message_uid != last_message_uid ): - self._last_message_id = last_message_id - await self._async_process_event(last_message_id) + self._last_message_uid = last_message_uid + await self._async_process_event(last_message_uid) return count diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index e7fca106ff7..ec864fd4665 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -22,6 +22,7 @@ TEST_MESSAGE_HEADERS2 = ( b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" + b"Message-ID: " ) TEST_MESSAGE_HEADERS3 = b"" diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b4ee11ba787..ceda841202c 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -512,6 +512,7 @@ async def test_reset_last_message( assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["text"] + assert data["initial"] assert ( valid_date and isinstance(data["date"], datetime) @@ -628,7 +629,7 @@ async def test_message_is_truncated( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), - ("{% bad template }}", None, "Error rendering imap custom template"), + ("{% bad template }}", None, "Error rendering IMAP custom template"), ], ids=["subject_test", "sender_filter", "template_error"], ) From a09372590f92112604fce2060f2d920d249a321a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 19:26:33 +0200 Subject: [PATCH 449/984] Use shorthand attributes in Smartthings (#100215) --- .../components/smartthings/__init__.py | 34 +++------ .../components/smartthings/binary_sensor.py | 24 ++----- homeassistant/components/smartthings/cover.py | 35 +++------ homeassistant/components/smartthings/fan.py | 6 +- homeassistant/components/smartthings/light.py | 52 ++++---------- homeassistant/components/smartthings/scene.py | 12 +--- .../components/smartthings/sensor.py | 71 +++++-------------- 7 files changed, 59 insertions(+), 175 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 22856bdb05b..cdf04be29f3 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -429,6 +429,17 @@ class SmartThingsEntity(Entity): """Initialize the instance.""" self._device = device self._dispatcher_remove = None + self._attr_name = device.label + self._attr_unique_id = device.device_id + self._attr_device_info = DeviceInfo( + configuration_url="https://account.smartthings.com", + identifiers={(DOMAIN, device.device_id)}, + manufacturer=device.status.ocf_manufacturer_name, + model=device.status.ocf_model_number, + name=device.label, + hw_version=device.status.ocf_hardware_version, + sw_version=device.status.ocf_firmware_version, + ) async def async_added_to_hass(self): """Device added to hass.""" @@ -446,26 +457,3 @@ class SmartThingsEntity(Entity): """Disconnect the device when removed.""" if self._dispatcher_remove: self._dispatcher_remove() - - @property - def device_info(self) -> DeviceInfo: - """Get attributes about the device.""" - return DeviceInfo( - configuration_url="https://account.smartthings.com", - identifiers={(DOMAIN, self._device.device_id)}, - manufacturer=self._device.status.ocf_manufacturer_name, - model=self._device.status.ocf_model_number, - name=self._device.label, - hw_version=self._device.status.ocf_hardware_version, - sw_version=self._device.status.ocf_firmware_version, - ) - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._device.label - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._device.device_id diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index d0ffd0ac29d..25f9fa224ff 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -73,28 +73,12 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): """Init the class.""" super().__init__(device) self._attribute = attribute - - @property - def name(self) -> str: - """Return the name of the binary sensor.""" - return f"{self._device.label} {self._attribute}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self._attribute}" + self._attr_name = f"{device.label} {attribute}" + self._attr_unique_id = f"{device.device_id}.{attribute}" + self._attr_device_class = ATTRIB_TO_CLASS[attribute] + self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute) @property def is_on(self): """Return true if the binary sensor is on.""" return self._device.status.is_on(self._attribute) - - @property - def device_class(self): - """Return the class of this device.""" - return ATTRIB_TO_CLASS[self._attribute] - - @property - def entity_category(self): - """Return the entity category of this device.""" - return ATTRIB_TO_ENTTIY_CATEGORY.get(self._attribute) diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 5d7e29c1312..83522c61794 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -77,10 +77,8 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): def __init__(self, device): """Initialize the cover class.""" super().__init__(device) - self._device_class = None self._current_cover_position = None self._state = None - self._state_attrs = None self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) @@ -90,6 +88,13 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): ): self._attr_supported_features |= CoverEntityFeature.SET_POSITION + if Capability.door_control in device.capabilities: + self._attr_device_class = CoverDeviceClass.DOOR + elif Capability.window_shade in device.capabilities: + self._attr_device_class = CoverDeviceClass.SHADE + elif Capability.garage_door_control in device.capabilities: + self._attr_device_class = CoverDeviceClass.GARAGE + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" # Same command for all 3 supported capabilities @@ -121,24 +126,21 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): async def async_update(self) -> None: """Update the attrs of the cover.""" if Capability.door_control in self._device.capabilities: - self._device_class = CoverDeviceClass.DOOR self._state = VALUE_TO_STATE.get(self._device.status.door) elif Capability.window_shade in self._device.capabilities: - self._device_class = CoverDeviceClass.SHADE self._state = VALUE_TO_STATE.get(self._device.status.window_shade) elif Capability.garage_door_control in self._device.capabilities: - self._device_class = CoverDeviceClass.GARAGE self._state = VALUE_TO_STATE.get(self._device.status.door) if Capability.window_shade_level in self._device.capabilities: - self._current_cover_position = self._device.status.shade_level + self._attr_current_cover_position = self._device.status.shade_level elif Capability.switch_level in self._device.capabilities: - self._current_cover_position = self._device.status.level + self._attr_current_cover_position = self._device.status.level - self._state_attrs = {} + self._attr_extra_state_attributes = {} battery = self._device.status.attributes[Attribute.battery].value if battery is not None: - self._state_attrs[ATTR_BATTERY_LEVEL] = battery + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery @property def is_opening(self) -> bool: @@ -156,18 +158,3 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): if self._state == STATE_CLOSED: return True return None if self._state is None else False - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover.""" - return self._current_cover_position - - @property - def device_class(self) -> CoverDeviceClass | None: - """Define this cover as a garage door.""" - return self._device_class - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Get additional state attributes.""" - return self._state_attrs diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 7278f350dc1..ebf80e22909 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -52,6 +52,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_speed_count = int_states_in_range(SPEED_RANGE) async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -94,8 +95,3 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): def percentage(self) -> int: """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) - - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 37237323d1c..58623e08394 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -75,12 +75,19 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): _attr_supported_color_modes: set[ColorMode] + # SmartThings does not expose this attribute, instead it's + # implemented within each device-type handler. This value is the + # lowest kelvin found supported across 20+ handlers. + _attr_max_mireds = 500 # 2000K + + # SmartThings does not expose this attribute, instead it's + # implemented within each device-type handler. This value is the + # highest kelvin found supported across 20+ handlers. + _attr_min_mireds = 111 # 9000K + def __init__(self, device): """Initialize a SmartThingsLight.""" super().__init__(device) - self._brightness = None - self._color_temp = None - self._hs_color = None self._attr_supported_color_modes = self._determine_color_modes() self._attr_supported_features = self._determine_features() @@ -151,17 +158,17 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): - self._brightness = int( + self._attr_brightness = int( convert_scale(self._device.status.level, 100, 255, 0) ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._color_temp = color_util.color_temperature_kelvin_to_mired( + self._attr_color_temp = color_util.color_temperature_kelvin_to_mired( self._device.status.color_temperature ) # Color if ColorMode.HS in self._attr_supported_color_modes: - self._hs_color = ( + self._attr_hs_color = ( convert_scale(self._device.status.hue, 100, 360), self._device.status.saturation, ) @@ -197,42 +204,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): return list(self._attr_supported_color_modes)[0] # The light supports hs + color temp, determine which one it is - if self._hs_color and self._hs_color[1]: + if self._attr_hs_color and self._attr_hs_color[1]: return ColorMode.HS return ColorMode.COLOR_TEMP - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def color_temp(self): - """Return the CT color value in mireds.""" - return self._color_temp - - @property - def hs_color(self): - """Return the hue and saturation color value [float, float].""" - return self._hs_color - @property def is_on(self) -> bool: """Return true if light is on.""" return self._device.status.switch - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the - # lowest kelvin found supported across 20+ handlers. - return 500 # 2000K - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the - # highest kelvin found supported across 20+ handlers. - return 111 # 9000K diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index 9ccda5fd5e6..ffdb900237e 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -25,6 +25,8 @@ class SmartThingsScene(Scene): def __init__(self, scene): """Init the scene class.""" self._scene = scene + self._attr_name = scene.name + self._attr_unique_id = scene.scene_id async def async_activate(self, **kwargs: Any) -> None: """Activate scene.""" @@ -38,13 +40,3 @@ class SmartThingsScene(Scene): "color": self._scene.color, "location_id": self._scene.location_id, } - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._scene.name - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._scene.scene_id diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 823ca793972..18016a88d29 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -629,44 +629,30 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): attribute: str, name: str, default_unit: str, - device_class: str, + device_class: SensorDeviceClass, state_class: str | None, entity_category: EntityCategory | None, ) -> None: """Init the class.""" super().__init__(device) self._attribute = attribute - self._name = name - self._device_class = device_class + self._attr_name = f"{device.label} {name}" + self._attr_unique_id = f"{device.device_id}.{attribute}" + self._attr_device_class = device_class self._default_unit = default_unit self._attr_state_class = state_class self._attr_entity_category = entity_category - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {self._name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self._attribute}" - @property def native_value(self): """Return the state of the sensor.""" value = self._device.status.attributes[self._attribute].value - if self._device_class != SensorDeviceClass.TIMESTAMP: + if self.device_class != SensorDeviceClass.TIMESTAMP: return value return dt_util.parse_datetime(value) - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" @@ -681,16 +667,8 @@ class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): """Init the class.""" super().__init__(device) self._index = index - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {THREE_AXIS_NAMES[self._index]}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{THREE_AXIS_NAMES[self._index]}" + self._attr_name = f"{device.label} {THREE_AXIS_NAMES[index]}" + self._attr_unique_id = f"{device.device_id} {THREE_AXIS_NAMES[index]}" @property def native_value(self): @@ -713,19 +691,16 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): """Init the class.""" super().__init__(device) self.report_name = report_name - self._attr_state_class = SensorStateClass.MEASUREMENT - if self.report_name != "power": + self._attr_name = f"{device.label} {report_name}" + self._attr_unique_id = f"{device.device_id}.{report_name}_meter" + if self.report_name == "power": + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_device_class = SensorDeviceClass.POWER + self._attr_native_unit_of_measurement = UnitOfPower.WATT + else: self._attr_state_class = SensorStateClass.TOTAL_INCREASING - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {self.report_name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self.report_name}_meter" + self._attr_device_class = SensorDeviceClass.ENERGY + self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR @property def native_value(self): @@ -737,20 +712,6 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): return value[self.report_name] return value[self.report_name] / 1000 - @property - def device_class(self): - """Return the device class of the sensor.""" - if self.report_name == "power": - return SensorDeviceClass.POWER - return SensorDeviceClass.ENERGY - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - if self.report_name == "power": - return UnitOfPower.WATT - return UnitOfEnergy.KILO_WATT_HOUR - @property def extra_state_attributes(self): """Return specific state attributes.""" From 44af34083bda14fefd1463bb41d3ac296cdce80f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 19:27:53 +0200 Subject: [PATCH 450/984] Remove unnecessary pylint disable in tado (#100196) --- homeassistant/components/tado/sensor.py | 91 ++++++++++++------------- 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index f7ba1682e18..c665cc3c592 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -52,6 +52,47 @@ class TadoSensorEntityDescription( data_category: str | None = None +def format_condition(condition: str) -> str: + """Return condition from dict CONDITIONS_MAP.""" + for key, value in CONDITIONS_MAP.items(): + if condition in value: + return key + return condition + + +def get_tado_mode(data) -> str | None: + """Return Tado Mode based on Presence attribute.""" + if "presence" in data: + return data["presence"] + return None + + +def get_automatic_geofencing(data) -> bool: + """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" + if "presenceLocked" in data: + if data["presenceLocked"]: + return False + return True + return False + + +def get_geofencing_mode(data) -> str: + """Return Geofencing Mode based on Presence and Presence Locked attributes.""" + tado_mode = "" + tado_mode = data.get("presence", "unknown") + + geofencing_switch_mode = "" + if "presenceLocked" in data: + if data["presenceLocked"]: + geofencing_switch_mode = "manual" + else: + geofencing_switch_mode = "auto" + else: + geofencing_switch_mode = "manual" + + return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})" + + HOME_SENSORS = [ TadoSensorEntityDescription( key="outdoor temperature", @@ -86,22 +127,19 @@ HOME_SENSORS = [ TadoSensorEntityDescription( key="tado mode", translation_key="tado_mode", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_tado_mode(data), + state_fn=get_tado_mode, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), TadoSensorEntityDescription( key="geofencing mode", translation_key="geofencing_mode", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_geofencing_mode(data), + state_fn=get_geofencing_mode, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), TadoSensorEntityDescription( key="automatic geofencing", translation_key="automatic_geofencing", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_automatic_geofencing(data), + state_fn=get_automatic_geofencing, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), ] @@ -163,47 +201,6 @@ ZONE_SENSORS = { } -def format_condition(condition: str) -> str: - """Return condition from dict CONDITIONS_MAP.""" - for key, value in CONDITIONS_MAP.items(): - if condition in value: - return key - return condition - - -def get_tado_mode(data) -> str | None: - """Return Tado Mode based on Presence attribute.""" - if "presence" in data: - return data["presence"] - return None - - -def get_automatic_geofencing(data) -> bool: - """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" - if "presenceLocked" in data: - if data["presenceLocked"]: - return False - return True - return False - - -def get_geofencing_mode(data) -> str: - """Return Geofencing Mode based on Presence and Presence Locked attributes.""" - tado_mode = "" - tado_mode = data.get("presence", "unknown") - - geofencing_switch_mode = "" - if "presenceLocked" in data: - if data["presenceLocked"]: - geofencing_switch_mode = "manual" - else: - geofencing_switch_mode = "auto" - else: - geofencing_switch_mode = "manual" - - return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})" - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: From f5c0c7bf27f0cc144f17ce76492c3f880b521731 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 12 Sep 2023 19:33:42 +0200 Subject: [PATCH 451/984] Bump homematicip_cloud to 1.0.15 (#99387) --- homeassistant/components/homematicip_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/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 1b86e36b826..c3d14b7d383 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.14"] + "requirements": ["homematicip==1.0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3c5ef0694b2..9b3192459aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ home-assistant-intents==2023.8.2 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.14 +homematicip==1.0.15 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec4ec481af6..656658f254f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -793,7 +793,7 @@ home-assistant-intents==2023.8.2 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.14 +homematicip==1.0.15 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 From 8af7475f73ff0c2d49d091d9f312e038b37ce90a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 12:36:56 -0500 Subject: [PATCH 452/984] Set TriggerBaseEntity device_class in init (#100216) --- homeassistant/helpers/trigger_template_entity.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 0ee653b42bd..bc7deceefef 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -119,6 +119,7 @@ class TriggerBaseEntity(Entity): # We make a copy so our initial render is 'unknown' and not 'unavailable' self._rendered = dict(self._static_rendered) self._parse_result = {CONF_AVAILABILITY} + self._attr_device_class = config.get(CONF_DEVICE_CLASS) @property def name(self) -> str | None: @@ -130,11 +131,6 @@ class TriggerBaseEntity(Entity): """Return unique ID of the entity.""" return self._unique_id - @property - def device_class(self): # type: ignore[no-untyped-def] - """Return device class of the entity.""" - return self._config.get(CONF_DEVICE_CLASS) - @property def icon(self) -> str | None: """Return icon.""" From 5021c6988699e1994127ffe8d46d93efa803e551 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Wed, 13 Sep 2023 01:38:11 +0800 Subject: [PATCH 453/984] Update Stream logging on EVENT_LOGGING_CHANGED (#99256) --- homeassistant/components/stream/__init__.py | 42 +++++++++---------- homeassistant/components/stream/manifest.json | 2 +- pyproject.toml | 1 + tests/components/stream/test_init.py | 9 +++- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 691ba262ee2..626a03b785f 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol from yarl import URL +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -188,36 +189,32 @@ CONFIG_SCHEMA = vol.Schema( ) -def filter_libav_logging() -> None: - """Filter libav logging to only log when the stream logger is at DEBUG.""" +@callback +def update_pyav_logging(_event: Event | None = None) -> None: + """Adjust libav logging to only log when the stream logger is at DEBUG.""" - def libav_filter(record: logging.LogRecord) -> bool: - return logging.getLogger(__name__).isEnabledFor(logging.DEBUG) + def set_pyav_logging(enable: bool) -> None: + """Turn PyAV logging on or off.""" + import av # pylint: disable=import-outside-toplevel - for logging_namespace in ( - "libav.NULL", - "libav.h264", - "libav.hevc", - "libav.hls", - "libav.mp4", - "libav.mpegts", - "libav.rtsp", - "libav.tcp", - "libav.tls", - ): - logging.getLogger(logging_namespace).addFilter(libav_filter) + av.logging.set_level(av.logging.VERBOSE if enable else av.logging.FATAL) - # Set log level to error for libav.mp4 - logging.getLogger("libav.mp4").setLevel(logging.ERROR) - # Suppress "deprecated pixel format" WARNING - logging.getLogger("libav.swscaler").setLevel(logging.ERROR) + # enable PyAV logging iff Stream logger is set to debug + set_pyav_logging(logging.getLogger(__name__).isEnabledFor(logging.DEBUG)) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up stream.""" - # Drop libav log messages if stream logging is above DEBUG - filter_libav_logging() + # Only pass through PyAV log messages if stream logging is above DEBUG + cancel_logging_listener = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, update_pyav_logging + ) + # libav.mp4 and libav.swscaler have a few unimportant messages that are logged + # at logging.WARNING. Set those Logger levels to logging.ERROR + for logging_namespace in ("libav.mp4", "libav.swscaler"): + logging.getLogger(logging_namespace).setLevel(logging.ERROR) + update_pyav_logging() # Keep import here so that we can import stream integration without installing reqs # pylint: disable-next=import-outside-toplevel @@ -258,6 +255,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ]: await asyncio.wait(awaitables) _LOGGER.debug("Stopped stream workers") + cancel_logging_listener() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 96474ceb7eb..47a4ddd0653 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], - "dependencies": ["http"], + "dependencies": ["http", "logger"], "documentation": "https://www.home-assistant.io/integrations/stream", "integration_type": "system", "iot_class": "local_push", diff --git a/pyproject.toml b/pyproject.toml index e62bdbf3e30..73f47998ea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,7 @@ load-plugins = [ persistent = false extension-pkg-allow-list = [ "av.audio.stream", + "av.logging", "av.stream", "ciso8601", "orjson", diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py index 0c625a8dec1..525eb9d859d 100644 --- a/tests/components/stream/test_init.py +++ b/tests/components/stream/test_init.py @@ -4,6 +4,7 @@ import logging import av import pytest +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.components.stream import __name__ as stream_name from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -14,8 +15,6 @@ async def test_log_levels( ) -> None: """Test that the worker logs the url without username and password.""" - logging.getLogger(stream_name).setLevel(logging.INFO) - await async_setup_component(hass, "stream", {"stream": {}}) # These namespaces should only pass log messages when the stream logger @@ -31,11 +30,17 @@ async def test_log_levels( "NULL", ) + logging.getLogger(stream_name).setLevel(logging.INFO) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() + # Since logging is at INFO, these should not pass for namespace in namespaces_to_toggle: av.logging.log(av.logging.ERROR, namespace, "SHOULD NOT PASS") logging.getLogger(stream_name).setLevel(logging.DEBUG) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() # Since logging is now at DEBUG, these should now pass for namespace in namespaces_to_toggle: From 74a57e86768b79ce95ebf9c581666f3be5799e33 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Tue, 12 Sep 2023 19:44:31 +0200 Subject: [PATCH 454/984] Use more common translations (#100135) --- homeassistant/components/bosch_shc/strings.json | 3 +++ homeassistant/components/climate/strings.json | 4 ++-- homeassistant/components/co2signal/strings.json | 2 +- homeassistant/components/dlink/strings.json | 12 +++++++++--- homeassistant/components/forecast_solar/strings.json | 2 +- .../homeassistant_sky_connect/strings.json | 2 +- .../components/homeassistant_yellow/strings.json | 2 +- homeassistant/components/hue/strings.json | 2 +- homeassistant/components/humidifier/strings.json | 4 ++-- homeassistant/components/jvc_projector/strings.json | 2 +- homeassistant/components/kodi/strings.json | 4 ++-- homeassistant/components/lawn_mower/strings.json | 2 +- homeassistant/components/mikrotik/strings.json | 2 +- homeassistant/components/netgear/strings.json | 4 ++-- homeassistant/components/octoprint/strings.json | 4 ++-- homeassistant/components/plugwise/strings.json | 3 +++ homeassistant/components/ps4/strings.json | 2 +- homeassistant/components/qnap/strings.json | 12 ++++++------ homeassistant/components/shelly/strings.json | 2 +- homeassistant/components/subaru/strings.json | 2 +- homeassistant/components/text/strings.json | 4 ++-- homeassistant/components/tibber/strings.json | 2 +- homeassistant/components/vulcan/strings.json | 8 ++++---- 23 files changed, 49 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 67462b78bec..90688e1373f 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -10,6 +10,9 @@ }, "credentials": { "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { "password": "Password of the Smart Home Controller" } }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index c517bfd7a20..55ccef2bc76 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -66,7 +66,7 @@ "heating": "Heating", "cooling": "Cooling", "drying": "Drying", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "fan": "Fan" } }, @@ -93,7 +93,7 @@ "away": "Away", "boost": "Boost", "comfort": "Comfort", - "home": "Home", + "home": "[%key:common::state::home%]", "sleep": "Sleep", "activity": "Activity" } diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 01c5673d4b1..7dbcd2e7966 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "location": "Get data for", + "location": "[%key:common::config_flow::data::location%]", "api_key": "[%key:common::config_flow::data::access_token%]" }, "description": "Visit https://electricitymaps.com/free-tier to request a token." diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index ee7abb3e979..8c60d59fa6b 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -4,21 +4,27 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "password": "Password (default: PIN code on the back)", + "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", "use_legacy_protocol": "Use legacy protocol" + }, + "data_description": { + "password": "Default: PIN code on the back." } }, "confirm_discovery": { "data": { - "password": "[%key:component::dlink::config::step::user::data::password%]", + "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", "use_legacy_protocol": "[%key:component::dlink::config::step::user::data::use_legacy_protocol%]" + }, + "data_description": { + "password": "[%key:component::dlink::config::step::user::data_description::password%]" } } }, "error": { - "cannot_connect": "Failed to connect/authenticate", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index 1413dba23d4..201a3cd415c 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -22,7 +22,7 @@ "init": { "description": "These values allow tweaking the Forecast.Solar result. Please refer to the documentation if a field is unclear.", "data": { - "api_key": "Forecast.Solar API Key (optional)", + "api_key": "[%key:common::config_flow::data::api_key%]", "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", "damping_morning": "Damping factor: adjusts the results in the morning", "damping_evening": "Damping factor: adjusts the results in the evening", diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 58fc0180743..2ed0026a48c 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -60,7 +60,7 @@ } }, "error": { - "unknown": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 68e87c06024..894d799d073 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -82,7 +82,7 @@ } }, "error": { - "unknown": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 6d65abc8d5f..326d08d1f7a 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -31,7 +31,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "not_hue_bridge": "Not a Hue bridge", - "invalid_host": "Invalid host" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } }, "device_automation": { diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 19a9a8eab77..1cdad10f2fb 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -33,7 +33,7 @@ "state": { "humidifying": "Humidifying", "drying": "Drying", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "off": "[%key:common::state::off%]" } }, @@ -60,7 +60,7 @@ "away": "Away", "boost": "Boost", "comfort": "Comfort", - "home": "Home", + "home": "[%key:common::state::home%]", "sleep": "Sleep", "auto": "Auto", "baby": "Baby" diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index 1f85c20fc72..6fdc5b4d12f 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -29,7 +29,7 @@ "error": { "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:component::jvc_projector::config::step::reauth_confirm::description%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } } } diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index f7ee375f990..51431b317d6 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -43,8 +43,8 @@ }, "device_automation": { "trigger_type": { - "turn_on": "[%key:common::device_automation::action_type::turn_on%]", - "turn_off": "[%key:common::device_automation::action_type::turn_off%]" + "turn_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turn_off": "[%key:common::device_automation::trigger_type::turned_off%]" } }, "services": { diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index caf2e15df77..15ed50ca6c5 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -5,7 +5,7 @@ "name": "[%key:component::lawn_mower::title%]", "state": { "error": "Error", - "paused": "Paused", + "paused": "[%key:common::state::paused%]", "mowing": "Mowing", "docked": "Docked" } diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index ec47d98b7a9..582450eca62 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -9,7 +9,7 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", - "verify_ssl": "Use ssl" + "verify_ssl": "[%key:common::config_flow::data::ssl%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 7941d1fe0a7..f2af3dd7804 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -4,8 +4,8 @@ "user": { "description": "Default host: {host}\nDefault username: {username}", "data": { - "host": "Host (Optional)", - "username": "Username (Optional)", + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } } diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 23cdf6ce56e..c6dbfe6f9c4 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -6,8 +6,8 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "path": "Application Path", - "port": "Port Number", - "ssl": "Use SSL", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" } diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 2714d657267..f85c83819fa 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -9,6 +9,9 @@ "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", "username": "Smile Username" + }, + "data_description": { + "host": "Leave empty if using Auto Discovery" } } }, diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 644b2d61216..163f2cc9b94 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -7,7 +7,7 @@ "mode": { "data": { "mode": "Config Mode", - "ip_address": "IP address (Leave empty if using Auto Discovery)." + "ip_address": "[%key:common::config_flow::data::ip%]" }, "data_description": { "ip_address": "Leave blank if selecting auto-discovery." diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 64b3f22293a..a5fa3c8a897 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -5,19 +5,19 @@ "title": "Connect to the QNAP device", "description": "This qnap sensor allows getting various statistics from your QNAP NAS.", "data": { - "host": "Hostname", + "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%]", - "ssl": "Enable SSL", - "verify_ssl": "Verify SSL" + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, "error": { - "cannot_connect": "Cannot connect to host", - "invalid_auth": "Bad authentication", - "unknown": "Unknown 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%]" } } } diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 043ff419742..dcdfa6d7987 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -120,7 +120,7 @@ "valve_status": { "state": { "checking": "Checking", - "closed": "Closed", + "closed": "[%key:common::state::closed%]", "closing": "Closing", "failure": "Failure", "opened": "Opened", diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 5e6db32d4ad..78625192e4a 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -28,7 +28,7 @@ "title": "[%key:component::subaru::config::step::user::title%]", "description": "Please enter your MySubaru PIN\nNOTE: All vehicles in account must have the same PIN", "data": { - "pin": "PIN" + "pin": "[%key:common::config_flow::data::pin%]" } } }, diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index e6b3d99ced4..82cab559d0e 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -16,10 +16,10 @@ "name": "Min length" }, "mode": { - "name": "Mode", + "name": "[%key:common::config_flow::data::mode%]", "state": { "text": "Text", - "password": "Password" + "password": "[%key:common::config_flow::data::password%]" } }, "pattern": { diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 2876bf5bd02..8306f25f587 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { - "timeout": "Timeout connecting to Tibber", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" }, diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index b2b270e3422..07a0510f482 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -7,13 +7,13 @@ "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student.." }, "error": { - "unknown": "Unknown error occurred", - "invalid_token": "Invalid token", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_token": "[%key:common::config_flow::error::invalid_access_token%]", "expired_token": "Expired token - please generate a new token", "invalid_pin": "Invalid pin", "invalid_symbol": "Invalid symbol", "expired_credentials": "Expired credentials - please create new on Vulcan mobile app registration page", - "cannot_connect": "Connection error - please check your internet connection" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { "auth": { @@ -21,7 +21,7 @@ "data": { "token": "Token", "region": "Symbol", - "pin": "Pin" + "pin": "[%key:common::config_flow::data::pin%]" } }, "reauth_confirm": { From 6a7d5a0fd43e2816ca8a2aa3e30b66cbbdf48843 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 12:45:44 -0500 Subject: [PATCH 455/984] Use more shorthand attributes in huawei_lte binary_sensor (#100211) --- .../components/huawei_lte/binary_sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 9966b9cc5f5..a1a26b51657 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -52,15 +52,12 @@ async def async_setup_entry( class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntityWithDevice, BinarySensorEntity): """Huawei LTE binary sensor device base class.""" + _attr_entity_registry_enabled_default = False + key: str = field(init=False) item: str = field(init=False) _raw_state: str | None = field(default=None, init=False) - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - @property def _device_unique_id(self) -> str: return f"{self.key}.{self.item}" @@ -106,6 +103,7 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE mobile connection binary sensor.""" _attr_name: str = field(default="Mobile connection", init=False) + _attr_entity_registry_enabled_default = True def __post_init__(self) -> None: """Initialize identifiers.""" @@ -135,11 +133,6 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Return mobile connectivity sensor icon.""" return "mdi:signal" if self.is_on else "mdi:signal-off" - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return True - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Get additional attributes related to connection status.""" From 42c35da81850570aae576898acf54729bce3e16a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 12:45:53 -0500 Subject: [PATCH 456/984] Use more shorthand properties in homematicip_cloud (#100210) --- .../homematicip_cloud/binary_sensor.py | 74 +++++-------------- .../components/homematicip_cloud/cover.py | 26 ++----- .../components/homematicip_cloud/weather.py | 12 +-- 3 files changed, 28 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 6730f722685..2afe803e1eb 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -194,10 +194,7 @@ class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEnt class HomematicipBaseActionSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP base action sensor.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOVING + _attr_device_class = BinarySensorDeviceClass.MOVING @property def is_on(self) -> bool: @@ -227,6 +224,8 @@ class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor): class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP multi room/area contact interface.""" + _attr_device_class = BinarySensorDeviceClass.OPENING + def __init__( self, hap: HomematicipHAP, @@ -239,11 +238,6 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt hap, device, channel=channel, is_multi_channel=is_multi_channel ) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.OPENING - @property def is_on(self) -> bool | None: """Return true if the contact interface is on/open.""" @@ -266,6 +260,8 @@ class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensor class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEntity): """Representation of the HomematicIP shutter contact.""" + _attr_device_class = BinarySensorDeviceClass.DOOR + def __init__( self, hap: HomematicipHAP, device, has_additional_state: bool = False ) -> None: @@ -273,11 +269,6 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn super().__init__(hap, device, is_multi_channel=False) self.has_additional_state = has_additional_state - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.DOOR - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the Shutter Contact.""" @@ -294,10 +285,7 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP motion detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOTION + _attr_device_class = BinarySensorDeviceClass.MOTION @property def is_on(self) -> bool: @@ -308,10 +296,7 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP presence detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.PRESENCE + _attr_device_class = BinarySensorDeviceClass.PRESENCE @property def is_on(self) -> bool: @@ -322,10 +307,7 @@ class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP smoke detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.SMOKE + _attr_device_class = BinarySensorDeviceClass.SMOKE @property def is_on(self) -> bool: @@ -341,10 +323,7 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP water detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOISTURE + _attr_device_class = BinarySensorDeviceClass.MOISTURE @property def is_on(self) -> bool: @@ -373,15 +352,12 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity): class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP rain sensor.""" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize rain sensor.""" super().__init__(hap, device, "Raining") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOISTURE - @property def is_on(self) -> bool: """Return true, if it is raining.""" @@ -391,15 +367,12 @@ class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP sunshine sensor.""" + _attr_device_class = BinarySensorDeviceClass.LIGHT + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize sunshine sensor.""" super().__init__(hap, device, post="Sunshine") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.LIGHT - @property def is_on(self) -> bool: """Return true if sun is shining.""" @@ -420,15 +393,12 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP low battery sensor.""" + _attr_device_class = BinarySensorDeviceClass.BATTERY + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize battery sensor.""" super().__init__(hap, device, post="Battery") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.BATTERY - @property def is_on(self) -> bool: """Return true if battery is low.""" @@ -440,15 +410,12 @@ class HomematicipPluggableMainsFailureSurveillanceSensor( ): """Representation of the HomematicIP pluggable mains failure surveillance sensor.""" + _attr_device_class = BinarySensorDeviceClass.POWER + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize pluggable mains failure surveillance sensor.""" super().__init__(hap, device) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.POWER - @property def is_on(self) -> bool: """Return true if power mains fails.""" @@ -458,16 +425,13 @@ class HomematicipPluggableMainsFailureSurveillanceSensor( class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP security zone sensor group.""" + _attr_device_class = BinarySensorDeviceClass.SAFETY + def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None: """Initialize security zone group.""" device.modelType = f"HmIP-{post}" super().__init__(hap, device, post=post) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.SAFETY - @property def available(self) -> bool: """Security-Group available.""" diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index e5007b5a15f..f5a9919579c 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -68,10 +68,7 @@ async def async_setup_entry( class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP blind module.""" - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.BLIND + _attr_device_class = CoverDeviceClass.BLIND @property def current_cover_position(self) -> int | None: @@ -149,6 +146,8 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP cover shutter.""" + _attr_device_class = CoverDeviceClass.SHUTTER + def __init__( self, hap: HomematicipHAP, @@ -161,11 +160,6 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): hap, device, channel=channel, is_multi_channel=is_multi_channel ) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.SHUTTER - @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -272,6 +266,8 @@ class HomematicipCoverSlats(HomematicipMultiCoverSlats, CoverEntity): class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP Garage Door Module.""" + _attr_device_class = CoverDeviceClass.GARAGE + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -283,11 +279,6 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): } return door_state_to_position.get(self._device.doorState) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.GARAGE - @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" @@ -309,16 +300,13 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP cover shutter group.""" + _attr_device_class = CoverDeviceClass.SHUTTER + def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" super().__init__(hap, device, post, is_multi_channel=False) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.SHUTTER - @property def current_cover_position(self) -> int | None: """Return current position of cover.""" diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index e913e1125f1..573f291d557 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -72,6 +72,7 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_attribution = "Powered by Homematic IP" def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the weather sensor.""" @@ -97,11 +98,6 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): """Return the wind speed.""" return self._device.windSpeed - @property - def attribution(self) -> str: - """Return the attribution.""" - return "Powered by Homematic IP" - @property def condition(self) -> str: """Return the current condition.""" @@ -128,6 +124,7 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_attribution = "Powered by Homematic IP" def __init__(self, hap: HomematicipHAP) -> None: """Initialize the home weather.""" @@ -164,11 +161,6 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): """Return the wind bearing.""" return self._device.weather.windDirection - @property - def attribution(self) -> str: - """Return the attribution.""" - return "Powered by Homematic IP" - @property def condition(self) -> str | None: """Return the current condition.""" From 9c775a8a24985cbba280ff1292efefe95e7df44a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 12:58:20 -0500 Subject: [PATCH 457/984] Set roku media player device class in constructor (#100225) --- homeassistant/components/roku/media_player.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 05f782b37c4..62a1a181459 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -122,6 +122,14 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.BROWSE_MEDIA ) + def __init__(self, coordinator: RokuDataUpdateCoordinator) -> None: + """Initialize the Roku device.""" + super().__init__(coordinator=coordinator) + if coordinator.data.info.device_type == "tv": + self._attr_device_class = MediaPlayerDeviceClass.TV + else: + self._attr_device_class = MediaPlayerDeviceClass.RECEIVER + def _media_playback_trackable(self) -> bool: """Detect if we have enough media data to track playback.""" if self.coordinator.data.media is None or self.coordinator.data.media.live: @@ -129,14 +137,6 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return self.coordinator.data.media.duration > 0 - @property - def device_class(self) -> MediaPlayerDeviceClass: - """Return the class of this device.""" - if self.coordinator.data.info.device_type == "tv": - return MediaPlayerDeviceClass.TV - - return MediaPlayerDeviceClass.RECEIVER - @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" From 69ac8a0a2be7d8d6302c19d9abd96011ce81e9d0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 20:05:57 +0200 Subject: [PATCH 458/984] Use shorthand attributes in NWS (#99620) --- homeassistant/components/nws/sensor.py | 25 +++++---------- homeassistant/components/nws/weather.py | 41 +++++-------------------- 2 files changed, 15 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 7c49ca278a7..ecf9d39ae55 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -23,7 +23,6 @@ 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 homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow @@ -163,6 +162,7 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): entity_description: NWSSensorEntityDescription _attr_attribution = ATTRIBUTION + _attr_entity_registry_enabled_default = False def __init__( self, @@ -175,13 +175,17 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): """Initialise the platform with a data instance.""" super().__init__(nws_data.coordinator_observation) self._nws = nws_data.api - self._latitude = entry_data[CONF_LATITUDE] - self._longitude = entry_data[CONF_LONGITUDE] + latitude = entry_data[CONF_LATITUDE] + longitude = entry_data[CONF_LONGITUDE] self.entity_description = description self._attr_name = f"{station} {description.name}" if hass.config.units is US_CUSTOMARY_SYSTEM: self._attr_native_unit_of_measurement = description.unit_convert + self._attr_device_info = device_info(latitude, longitude) + self._attr_unique_id = ( + f"{base_unique_id(latitude, longitude)}_{description.key}" + ) @property def native_value(self) -> float | None: @@ -219,11 +223,6 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): return round(value) return value - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return f"{base_unique_id(self._latitude, self._longitude)}_{self.entity_description.key}" - @property def available(self) -> bool: """Return if state is available.""" @@ -235,13 +234,3 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): else: last_success_time = False return self.coordinator.last_update_success or last_success_time - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return device_info(self._latitude, self._longitude) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 0f594133f69..d68b1fc745c 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -32,7 +32,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter @@ -121,6 +120,10 @@ class NWSWeather(CoordinatorWeatherEntity): _attr_supported_features = ( WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY ) + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.PA + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_visibility_unit = UnitOfLength.METERS def __init__( self, @@ -137,8 +140,8 @@ class NWSWeather(CoordinatorWeatherEntity): twice_daily_forecast_valid=FORECAST_VALID_TIME, ) self.nws = nws_data.api - self.latitude = entry_data[CONF_LATITUDE] - self.longitude = entry_data[CONF_LONGITUDE] + latitude = entry_data[CONF_LATITUDE] + longitude = entry_data[CONF_LONGITUDE] if mode == DAYNIGHT: self.coordinator_forecast_legacy = nws_data.coordinator_forecast else: @@ -153,6 +156,8 @@ class NWSWeather(CoordinatorWeatherEntity): self._forecast_twice_daily: list[dict[str, Any]] | None = None self._attr_unique_id = _calculate_unique_id(entry_data, mode) + self._attr_device_info = device_info(latitude, longitude) + self._attr_name = f"{self.station} {self.mode.title()}" async def async_added_to_hass(self) -> None: """Set up a listener and load data.""" @@ -193,11 +198,6 @@ class NWSWeather(CoordinatorWeatherEntity): self._forecast_legacy = self.nws.forecast_hourly self.async_write_ha_state() - @property - def name(self) -> str: - """Return the name of the station.""" - return f"{self.station} {self.mode.title()}" - @property def native_temperature(self) -> float | None: """Return the current temperature.""" @@ -205,11 +205,6 @@ class NWSWeather(CoordinatorWeatherEntity): return self.observation.get("temperature") return None - @property - def native_temperature_unit(self) -> str: - """Return the current temperature unit.""" - return UnitOfTemperature.CELSIUS - @property def native_pressure(self) -> int | None: """Return the current pressure.""" @@ -217,11 +212,6 @@ class NWSWeather(CoordinatorWeatherEntity): return self.observation.get("seaLevelPressure") return None - @property - def native_pressure_unit(self) -> str: - """Return the current pressure unit.""" - return UnitOfPressure.PA - @property def humidity(self) -> float | None: """Return the name of the sensor.""" @@ -236,11 +226,6 @@ class NWSWeather(CoordinatorWeatherEntity): return self.observation.get("windSpeed") return None - @property - def native_wind_speed_unit(self) -> str: - """Return the current windspeed.""" - return UnitOfSpeed.KILOMETERS_PER_HOUR - @property def wind_bearing(self) -> int | None: """Return the current wind bearing (degrees).""" @@ -267,11 +252,6 @@ class NWSWeather(CoordinatorWeatherEntity): return self.observation.get("visibility") return None - @property - def native_visibility_unit(self) -> str: - """Return visibility unit.""" - return UnitOfLength.METERS - def _forecast( self, nws_forecast: list[dict[str, Any]] | None, mode: str ) -> list[Forecast] | None: @@ -377,8 +357,3 @@ class NWSWeather(CoordinatorWeatherEntity): def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" return self.mode == DAYNIGHT - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return device_info(self.latitude, self.longitude) From a9891e40fd21be7772e5e02f915c55cd34926441 Mon Sep 17 00:00:00 2001 From: James Chaloupka <47349533+SirGoodenough@users.noreply.github.com> Date: Tue, 12 Sep 2023 13:10:32 -0500 Subject: [PATCH 459/984] Update Deprecated Selector Syntax (#99308) --- .../components/automation/blueprints/motion_light.yaml | 5 +++-- .../automation/blueprints/notify_leaving_zone.yaml | 9 ++++++--- .../script/blueprints/confirmable_notification.yaml | 3 ++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/automation/blueprints/motion_light.yaml b/homeassistant/components/automation/blueprints/motion_light.yaml index 5b389a3fc26..8f5d3f957f9 100644 --- a/homeassistant/components/automation/blueprints/motion_light.yaml +++ b/homeassistant/components/automation/blueprints/motion_light.yaml @@ -9,8 +9,9 @@ blueprint: name: Motion Sensor selector: entity: - domain: binary_sensor - device_class: motion + filter: + device_class: motion + domain: binary_sensor light_target: name: Light selector: diff --git a/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml b/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml index 0798a051173..e1e3bd5b2f6 100644 --- a/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml +++ b/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml @@ -9,18 +9,21 @@ blueprint: name: Person selector: entity: - domain: person + filter: + domain: person zone_entity: name: Zone selector: entity: - domain: zone + filter: + domain: zone notify_device: name: Device to notify description: Device needs to run the official Home Assistant app to receive notifications. selector: device: - integration: mobile_app + filter: + integration: mobile_app trigger: platform: state diff --git a/homeassistant/components/script/blueprints/confirmable_notification.yaml b/homeassistant/components/script/blueprints/confirmable_notification.yaml index 37e04351d9a..c5f42494f02 100644 --- a/homeassistant/components/script/blueprints/confirmable_notification.yaml +++ b/homeassistant/components/script/blueprints/confirmable_notification.yaml @@ -12,7 +12,8 @@ blueprint: description: Device needs to run the official Home Assistant app to receive notifications. selector: device: - integration: mobile_app + filter: + integration: mobile_app title: name: "Title" description: "The title of the button shown in the notification." From 93f3bc6c2bd591cdcdedcb11f75e5b86c8c36b8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 20:11:12 +0200 Subject: [PATCH 460/984] Bump sigstore/cosign-installer from 3.1.1 to 3.1.2 (#99563) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 6ac535647b8..0694b1b75e0 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -334,7 +334,7 @@ jobs: uses: actions/checkout@v4.0.0 - name: Install Cosign - uses: sigstore/cosign-installer@v3.1.1 + uses: sigstore/cosign-installer@v3.1.2 with: cosign-release: "v2.0.2" From 70c6bceaee828cedf7f5328b2de92828e2dc312f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 13:12:14 -0500 Subject: [PATCH 461/984] Use short hand entity_registry_enabled_default in nws (#100227) * Use short hand entity_registry_enabled_default in nws see https://github.com/home-assistant/core/pull/95315 * Update homeassistant/components/nws/sensor.py --- homeassistant/components/nws/weather.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index d68b1fc745c..9d41e54ccd0 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -149,6 +149,7 @@ class NWSWeather(CoordinatorWeatherEntity): self.station = self.nws.station self.mode = mode + self._attr_entity_registry_enabled_default = mode == DAYNIGHT self.observation: dict[str, Any] | None = None self._forecast_hourly: list[dict[str, Any]] | None = None @@ -352,8 +353,3 @@ class NWSWeather(CoordinatorWeatherEntity): """ await self.coordinator.async_request_refresh() await self.coordinator_forecast_legacy.async_request_refresh() - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.mode == DAYNIGHT From 9672cdf3a9bf000f2404cf7d5cac92fb0a791ece Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 20:19:45 +0200 Subject: [PATCH 462/984] Add entity translations to WLED (#99056) --- homeassistant/components/wled/button.py | 1 - homeassistant/components/wled/coordinator.py | 2 +- homeassistant/components/wled/light.py | 4 +- homeassistant/components/wled/select.py | 5 +- homeassistant/components/wled/sensor.py | 20 +++---- homeassistant/components/wled/strings.json | 55 +++++++++++++++++++ homeassistant/components/wled/switch.py | 6 +- homeassistant/components/wled/update.py | 1 - .../wled/snapshots/test_select.ambr | 4 +- .../wled/snapshots/test_switch.ambr | 6 +- tests/components/wled/test_light.py | 46 ++++++++-------- 11 files changed, 101 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py index 2f9e1162763..430ee067486 100644 --- a/homeassistant/components/wled/button.py +++ b/homeassistant/components/wled/button.py @@ -28,7 +28,6 @@ class WLEDRestartButton(WLEDEntity, ButtonEntity): _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Restart" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize the button entity.""" diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 9ba3fd2cb3d..6f3bae03bfa 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -46,7 +46,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): @property def has_master_light(self) -> bool: - """Return if the coordinated device has an master light.""" + """Return if the coordinated device has a master light.""" return self.keep_master_light or ( self.data is not None and len(self.data.state.segments) > 1 ) diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 1eb8074bbc1..6675118e565 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -52,7 +52,7 @@ class WLEDMasterLight(WLEDEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_icon = "mdi:led-strip-variant" - _attr_name = "Master" + _attr_translation_key = "main" _attr_supported_features = LightEntityFeature.TRANSITION _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -200,7 +200,7 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): # WLED uses 100ms per unit, so 10 = 1 second. transition = round(kwargs[ATTR_TRANSITION] * 10) - # If there is no master control, and only 1 segment, handle the + # If there is no master control, and only 1 segment, handle the master if not self.coordinator.has_master_light: await self.coordinator.wled.master(on=False, transition=transition) return diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index c31f8e1277e..977c76025ac 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -50,7 +50,6 @@ class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:theater" - _attr_name = "Live override" _attr_translation_key = "live_override" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -75,7 +74,7 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): """Defined a WLED Preset select.""" _attr_icon = "mdi:playlist-play" - _attr_name = "Preset" + _attr_translation_key = "preset" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED .""" @@ -106,7 +105,7 @@ class WLEDPlaylistSelect(WLEDEntity, SelectEntity): """Define a WLED Playlist select.""" _attr_icon = "mdi:play-speed" - _attr_name = "Playlist" + _attr_translation_key = "playlist" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED playlist.""" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 668b90159b5..7d1431c093b 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -50,7 +50,7 @@ class WLEDSensorEntityDescription( SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="estimated_current", - name="Estimated current", + translation_key="estimated_current", native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -60,13 +60,13 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( ), WLEDSensorEntityDescription( key="info_leds_count", - name="LED count", + translation_key="info_leds_count", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.leds.count, ), WLEDSensorEntityDescription( key="info_leds_max_power", - name="Max current", + translation_key="info_leds_max_power", native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.CURRENT, @@ -75,7 +75,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( ), WLEDSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -83,7 +83,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( ), WLEDSensorEntityDescription( key="free_heap", - name="Free memory", + translation_key="free_heap", icon="mdi:memory", native_unit_of_measurement=UnitOfInformation.BYTES, state_class=SensorStateClass.MEASUREMENT, @@ -94,7 +94,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( ), WLEDSensorEntityDescription( key="wifi_signal", - name="Wi-Fi signal", + translation_key="wifi_signal", icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -104,7 +104,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( ), WLEDSensorEntityDescription( key="wifi_rssi", - name="Wi-Fi RSSI", + translation_key="wifi_rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -114,7 +114,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( ), WLEDSensorEntityDescription( key="wifi_channel", - name="Wi-Fi channel", + translation_key="wifi_channel", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -122,7 +122,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( ), WLEDSensorEntityDescription( key="wifi_bssid", - name="Wi-Fi BSSID", + translation_key="wifi_bssid", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -130,7 +130,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( ), WLEDSensorEntityDescription( key="ip", - name="IP", + translation_key="ip", icon="mdi:ip-network", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.ip, diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 9fc6573b112..5791732dfbe 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -32,13 +32,68 @@ } }, "entity": { + "light": { + "main": { + "name": "Main" + } + }, "select": { "live_override": { + "name": "Live override", "state": { "0": "[%key:common::state::off%]", "1": "[%key:common::state::on%]", "2": "Until device restarts" } + }, + "preset": { + "name": "Preset" + }, + "playlist": { + "name": "Playlist" + } + }, + "sensor": { + "estimated_current": { + "name": "Estimated current" + }, + "info_leds_count": { + "name": "LED count" + }, + "info_leds_max_power": { + "name": "Max current" + }, + "uptime": { + "name": "Uptime" + }, + "free_heap": { + "name": "Free memory" + }, + "wifi_signal": { + "name": "Wi-Fi signal" + }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + }, + "wifi_channel": { + "name": "Wi-Fi channel" + }, + "wifi_bssid": { + "name": "Wi-Fi BSSID" + }, + "ip": { + "name": "IP" + } + }, + "switch": { + "nightlight": { + "name": "Nightlight" + }, + "sync_send": { + "name": "Sync send" + }, + "sync_receive": { + "name": "Sync receive" } } }, diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 99b875c1642..680684e96df 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -55,7 +55,7 @@ class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:weather-night" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Nightlight" + _attr_translation_key = "nightlight" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED nightlight switch.""" @@ -93,7 +93,7 @@ class WLEDSyncSendSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:upload-network-outline" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Sync send" + _attr_translation_key = "sync_send" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED sync send switch.""" @@ -126,7 +126,7 @@ class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:download-network-outline" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Sync receive" + _attr_translation_key = "sync_receive" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED sync receive switch.""" diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py index 75546fdac1a..954279366be 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -36,7 +36,6 @@ class WLEDUpdateEntity(WLEDEntity, UpdateEntity): UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION ) _attr_title = "WLED" - _attr_name = "Firmware" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize the update entity.""" diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 05d61fc18cb..9cfc6c6e3fe 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -310,7 +310,7 @@ 'original_name': 'Playlist', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'playlist', 'unique_id': 'aabbccddee11_playlist', 'unit_of_measurement': None, }) @@ -393,7 +393,7 @@ 'original_name': 'Preset', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'preset', 'unique_id': 'aabbccddee11_preset', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index f89bde6ee17..1434d2b2b2d 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -40,7 +40,7 @@ 'original_name': 'Nightlight', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'nightlight', 'unique_id': 'aabbccddeeff_nightlight', 'unit_of_measurement': None, }) @@ -189,7 +189,7 @@ 'original_name': 'Sync receive', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'sync_receive', 'unique_id': 'aabbccddeeff_sync_receive', 'unit_of_measurement': None, }) @@ -264,7 +264,7 @@ 'original_name': 'Sync send', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'sync_send', 'unique_id': 'aabbccddeeff_sync_send', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 678b4a44459..ab8330293ba 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -60,12 +60,12 @@ async def test_rgb_light_state( assert (entry := entity_registry.async_get("light.wled_rgb_light_segment_1")) assert entry.unique_id == "aabbccddeeff_1" - # Test master control of the lightstrip - assert (state := hass.states.get("light.wled_rgb_light_master")) + # Test main control of the lightstrip + assert (state := hass.states.get("light.wled_rgb_light_main")) assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.state == STATE_ON - assert (entry := entity_registry.async_get("light.wled_rgb_light_master")) + assert (entry := entity_registry.async_get("light.wled_rgb_light_main")) assert entry.unique_id == "aabbccddeeff" @@ -110,15 +110,15 @@ async def test_segment_change_state( ) -async def test_master_change_state( +async def test_main_change_state( hass: HomeAssistant, mock_wled: MagicMock, ) -> None: - """Test the change of state of the WLED master light control.""" + """Test the change of state of the WLED main light control.""" await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_master", ATTR_TRANSITION: 5}, + {ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5}, blocking=True, ) assert mock_wled.master.call_count == 1 @@ -132,7 +132,7 @@ async def test_master_change_state( SERVICE_TURN_ON, { ATTR_BRIGHTNESS: 42, - ATTR_ENTITY_ID: "light.wled_rgb_light_master", + ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5, }, blocking=True, @@ -147,7 +147,7 @@ async def test_master_change_state( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_master", ATTR_TRANSITION: 5}, + {ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5}, blocking=True, ) assert mock_wled.master.call_count == 3 @@ -161,7 +161,7 @@ async def test_master_change_state( SERVICE_TURN_ON, { ATTR_BRIGHTNESS: 42, - ATTR_ENTITY_ID: "light.wled_rgb_light_master", + ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5, }, blocking=True, @@ -183,7 +183,7 @@ async def test_dynamically_handle_segments( """Test if a new/deleted segment is dynamically added/removed.""" assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") assert not hass.states.get("light.wled_rgb_light_segment_1") return_value = mock_wled.update.return_value @@ -195,21 +195,21 @@ async def test_dynamically_handle_segments( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (master := hass.states.get("light.wled_rgb_light_master")) - assert master.state == STATE_ON + assert (main := hass.states.get("light.wled_rgb_light_main")) + assert main.state == STATE_ON assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON assert (segment1 := hass.states.get("light.wled_rgb_light_segment_1")) assert segment1.state == STATE_ON - # Test adding if segment shows up again, including the master entity + # Test adding if segment shows up again, including the main entity mock_wled.update.return_value = return_value freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert (master := hass.states.get("light.wled_rgb_light_master")) - assert master.state == STATE_UNAVAILABLE + assert (main := hass.states.get("light.wled_rgb_light_main")) + assert main.state == STATE_UNAVAILABLE assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON assert (segment1 := hass.states.get("light.wled_rgb_light_segment_1")) @@ -225,11 +225,11 @@ async def test_single_segment_behavior( """Test the behavior of the integration with a single segment.""" device = mock_wled.update.return_value - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") assert (state := hass.states.get("light.wled_rgb_light")) assert state.state == STATE_ON - # Test segment brightness takes master into account + # Test segment brightness takes main into account device.state.brightness = 100 device.state.segments[0].brightness = 255 freezer.tick(SCAN_INTERVAL) @@ -239,7 +239,7 @@ async def test_single_segment_behavior( assert (state := hass.states.get("light.wled_rgb_light")) assert state.attributes.get(ATTR_BRIGHTNESS) == 100 - # Test segment is off when master is off + # Test segment is off when main is off device.state.on = False freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -248,7 +248,7 @@ async def test_single_segment_behavior( assert state assert state.state == STATE_OFF - # Test master is turned off when turning off a single segment + # Test main is turned off when turning off a single segment await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -261,7 +261,7 @@ async def test_single_segment_behavior( transition=50, ) - # Test master is turned on when turning on a single segment, and segment + # Test main is turned on when turning on a single segment, and segment # brightness is set to 255. await hass.services.async_call( LIGHT_DOMAIN, @@ -346,18 +346,18 @@ async def test_rgbw_light(hass: HomeAssistant, mock_wled: MagicMock) -> None: @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) -async def test_single_segment_with_keep_master_light( +async def test_single_segment_with_keep_main_light( hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the behavior of the integration with a single segment.""" - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") hass.config_entries.async_update_entry( init_integration, options={CONF_KEEP_MASTER_LIGHT: True} ) await hass.async_block_till_done() - assert (state := hass.states.get("light.wled_rgb_light_master")) + assert (state := hass.states.get("light.wled_rgb_light_main")) assert state.state == STATE_ON From 693a271e4017f91161dce5359dcc5de5e3c57203 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 12 Sep 2023 14:29:47 -0400 Subject: [PATCH 463/984] Clean up device registry for climate devices that no longer exist in Honeywell (#100072) --- homeassistant/components/honeywell/climate.py | 32 +++++++++++ tests/components/honeywell/test_init.py | 55 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index d12d90a02c3..c285ab83bd1 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -28,6 +28,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,6 +98,37 @@ async def async_setup_entry( for device in data.devices.values() ] ) + remove_stale_devices(hass, entry, data.devices) + + +def remove_stale_devices( + hass: HomeAssistant, + config_entry: ConfigEntry, + devices: dict[str, SomeComfortDevice], +) -> None: + """Remove stale devices from device registry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + all_device_ids: list = [] + for device in devices.values(): + all_device_ids.append(device.deviceid) + + for device_entry in device_entries: + device_id: str | None = None + + for identifier in device_entry.identifiers: + device_id = identifier[1] + break + + if device_id is None or device_id not in all_device_ids: + # If device_id is None an invalid device entry was found for this config entry. + # If the device_id is not in existing device ids it's a stale device entry. + # Remove config entry from this device entry in either case. + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) class HoneywellUSThermostat(ClimateEntity): diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index f7629fa958e..e5afe311295 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -12,6 +12,7 @@ from homeassistant.components.honeywell.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import init_integration @@ -33,7 +34,10 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) - async def test_setup_multiple_thermostats( - hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device + hass: HomeAssistant, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, ) -> None: """Test that the config form is shown.""" location.devices_by_id[another_device.deviceid] = another_device @@ -50,8 +54,8 @@ async def test_setup_multiple_thermostats_with_same_deviceid( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_entry: MockConfigEntry, - device, - client, + device: MagicMock, + client: MagicMock, ) -> None: """Test Honeywell TCC API returning duplicate device IDs.""" mock_location2 = create_autospec(aiosomecomfort.Location, instance=True) @@ -115,3 +119,48 @@ async def test_no_devices( client.locations_by_id = {} await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_remove_stale_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, + client: MagicMock, +) -> None: + """Test that the stale device is removed.""" + location.devices_by_id[another_device.deviceid] = another_device + 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.state is ConfigEntryState.LOADED + assert ( + hass.states.async_entity_ids_count() == 6 + ) # 2 climate entities; 4 sensor entities + + device_registry = dr.async_get(hass) + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entry) == 2 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) + assert any((DOMAIN, 7654321) in device.identifiers for device in device_entry) + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + del location.devices_by_id[another_device.deviceid] + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.async_entity_ids_count() == 3 + ) # 1 climate entities; 2 sensor entities + + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entry) == 1 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) From cc252f705fa6b1a28e08a7b3ac667cbf56a46a71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 13:34:50 -0500 Subject: [PATCH 464/984] Use short handle attributes for device class in netatmo cover (#100228) --- homeassistant/components/netatmo/cover.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 41bf84c8334..2e4bf9e7d3c 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -51,6 +51,7 @@ class NetatmoCover(NetatmoBase, CoverEntity): | CoverEntityFeature.STOP | CoverEntityFeature.SET_POSITION ) + _attr_device_class = CoverDeviceClass.SHUTTER def __init__(self, netatmo_device: NetatmoDevice) -> None: """Initialize the Netatmo device.""" @@ -98,11 +99,6 @@ class NetatmoCover(NetatmoBase, CoverEntity): """Move the cover shutter to a specific position.""" await self._cover.async_set_target_position(kwargs[ATTR_POSITION]) - @property - def device_class(self) -> CoverDeviceClass: - """Return the device class.""" - return CoverDeviceClass.SHUTTER - @callback def async_update_callback(self) -> None: """Update the entity's state.""" From 51576b7214e25693309252ffe77b20d1c682679a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 20:41:26 +0200 Subject: [PATCH 465/984] Improve typing of entity.entity_sources (#99407) * Improve typing of entity.entity_sources * Calculate entity info source when generating WS response * Adjust typing * Update tests --- homeassistant/components/alexa/entities.py | 3 +- .../components/recorder/db_schema.py | 3 +- homeassistant/components/search/__init__.py | 7 ++-- homeassistant/components/sensor/recorder.py | 11 ++++--- .../components/websocket_api/commands.py | 2 +- homeassistant/helpers/entity.py | 32 +++++++++++++------ tests/components/search/test_init.py | 5 --- tests/helpers/test_entity.py | 2 -- 8 files changed, 39 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 7f6331515c6..da0bd8b36aa 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -707,7 +707,8 @@ class MediaPlayerCapabilities(AlexaEntity): # AlexaEqualizerController is disabled for denonavr # since it blocks alexa from discovering any devices. - domain = entity_sources(self.hass).get(self.entity_id, {}).get("domain") + entity_info = entity_sources(self.hass).get(self.entity_id) + domain = entity_info["domain"] if entity_info else None if ( supported & media_player.MediaPlayerEntityFeature.SELECT_SOUND_MODE and domain != "denonavr" diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 508874c54e5..e25c6d6dd5f 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -40,6 +40,7 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.entity import EntityInfo from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null import homeassistant.util.dt as dt_util from homeassistant.util.json import ( @@ -558,7 +559,7 @@ class StateAttributes(Base): @staticmethod def shared_attrs_bytes_from_event( event: Event, - entity_sources: dict[str, dict[str, str]], + entity_sources: dict[str, EntityInfo], exclude_attrs_by_domain: dict[str, set[str]], dialect: SupportedDialect | None, ) -> bytes: diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 69796800e61..ac9a13850d6 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -15,7 +15,10 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import entity_sources as get_entity_sources +from homeassistant.helpers.entity import ( + EntityInfo, + entity_sources as get_entity_sources, +) from homeassistant.helpers.typing import ConfigType DOMAIN = "search" @@ -97,7 +100,7 @@ class Searcher: hass: HomeAssistant, device_reg: dr.DeviceRegistry, entity_reg: er.EntityRegistry, - entity_sources: dict[str, dict[str, str]], + entity_sources: dict[str, EntityInfo], ) -> None: """Search results.""" self.hass = hass diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index e5a35187c99..63096b16cd8 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -262,8 +262,9 @@ def _normalize_states( def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: """Suggest to report an issue.""" - domain = entity_sources(hass).get(entity_id, {}).get("domain") - custom_component = entity_sources(hass).get(entity_id, {}).get("custom_component") + entity_info = entity_sources(hass).get(entity_id) + domain = entity_info["domain"] if entity_info else None + custom_component = entity_info["custom_component"] if entity_info else None report_issue = "" if custom_component: report_issue = "report it to the custom integration author." @@ -296,7 +297,8 @@ def warn_dip( hass.data[WARN_DIP] = set() if entity_id not in hass.data[WARN_DIP]: hass.data[WARN_DIP].add(entity_id) - domain = entity_sources(hass).get(entity_id, {}).get("domain") + entity_info = entity_sources(hass).get(entity_id) + domain = entity_info["domain"] if entity_info else None if domain in ["energy", "growatt_server", "solaredge"]: return _LOGGER.warning( @@ -320,7 +322,8 @@ def warn_negative(hass: HomeAssistant, entity_id: str, state: State) -> None: hass.data[WARN_NEGATIVE] = set() if entity_id not in hass.data[WARN_NEGATIVE]: hass.data[WARN_NEGATIVE].add(entity_id) - domain = entity_sources(hass).get(entity_id, {}).get("domain") + entity_info = entity_sources(hass).get(entity_id) + domain = entity_info["domain"] if entity_info else None _LOGGER.warning( ( "Entity %s %shas state class total_increasing, but its state is " diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 66866197081..e140fef861e 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -596,7 +596,7 @@ async def handle_render_template( def _serialize_entity_sources( - entity_infos: dict[str, dict[str, str]] + entity_infos: dict[str, entity.EntityInfo] ) -> dict[str, Any]: """Prepare a websocket response from a dict of entity sources.""" result = {} diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7bd510b6fa1..99c71e2cc86 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -12,7 +12,16 @@ import logging import math import sys from timeit import default_timer as timer -from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, final +from typing import ( + TYPE_CHECKING, + Any, + Final, + Literal, + NotRequired, + TypedDict, + TypeVar, + final, +) import voluptuous as vol @@ -60,8 +69,6 @@ _T = TypeVar("_T") _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 DATA_ENTITY_SOURCE = "entity_info" -SOURCE_CONFIG_ENTRY = "config_entry" -SOURCE_PLATFORM_CONFIG = "platform_config" # Used when converting float states to string: limit precision according to machine # epsilon to make the string representation readable @@ -76,9 +83,9 @@ def async_setup(hass: HomeAssistant) -> None: @callback @bind_hass -def entity_sources(hass: HomeAssistant) -> dict[str, dict[str, str]]: +def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]: """Get the entity sources.""" - _entity_sources: dict[str, dict[str, str]] = hass.data[DATA_ENTITY_SOURCE] + _entity_sources: dict[str, EntityInfo] = hass.data[DATA_ENTITY_SOURCE] return _entity_sources @@ -181,6 +188,14 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: ENTITY_CATEGORIES_SCHEMA: Final = vol.Coerce(EntityCategory) +class EntityInfo(TypedDict): + """Entity info.""" + + domain: str + custom_component: bool + config_entry: NotRequired[str] + + class EntityPlatformState(Enum): """The platform state of an entity.""" @@ -1061,18 +1076,15 @@ class Entity(ABC): Not to be extended by integrations. """ - info = { + info: EntityInfo = { "domain": self.platform.platform_name, "custom_component": "custom_components" in type(self).__module__, } if self.platform.config_entry: - info["source"] = SOURCE_CONFIG_ENTRY info["config_entry"] = self.platform.config_entry.entry_id - else: - info["source"] = SOURCE_PLATFORM_CONFIG - self.hass.data[DATA_ENTITY_SOURCE][self.entity_id] = info + entity_sources(self.hass)[self.entity_id] = info if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 40ec9c22afe..ebf70a6239c 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -6,7 +6,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, device_registry as dr, - entity, entity_registry as er, ) from homeassistant.setup import async_setup_component @@ -22,11 +21,9 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: MOCK_ENTITY_SOURCES = { "light.platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, "domain": "wled", }, "light.config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": "config_entry_id", "domain": "wled", }, @@ -73,11 +70,9 @@ async def test_search( entity_sources = { "light.wled_platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, "domain": "wled", }, "light.wled_config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": wled_config_entry.entry_id, "domain": "wled", }, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 20bea6a98eb..68eed5b6e32 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -795,13 +795,11 @@ async def test_setup_source(hass: HomeAssistant) -> None: "test_domain.platform_config_source": { "custom_component": False, "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.config_entry_source": { "config_entry": platform.config_entry.entry_id, "custom_component": False, "domain": "test_platform", - "source": entity.SOURCE_CONFIG_ENTRY, }, } From 5fcb69e004e162e69c34a3974fcff75d4e0bc5d7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 20:46:43 +0200 Subject: [PATCH 466/984] Use shorthanded attributes for MQTT cover (#100230) --- homeassistant/components/mqtt/cover.py | 45 +++++++++++--------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c11cf2dfb85..ae22eb675ac 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -13,7 +13,6 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DEVICE_CLASSES_SCHEMA, - CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -335,6 +334,25 @@ class MqttCover(MqttEntity, CoverEntity): config_attributes=template_config_attributes, ).async_render_with_possible_json_value + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + + supported_features = CoverEntityFeature(0) + if self._config.get(CONF_COMMAND_TOPIC) is not None: + if self._config.get(CONF_PAYLOAD_OPEN) is not None: + supported_features |= CoverEntityFeature.OPEN + if self._config.get(CONF_PAYLOAD_CLOSE) is not None: + supported_features |= CoverEntityFeature.CLOSE + if self._config.get(CONF_PAYLOAD_STOP) is not None: + supported_features |= CoverEntityFeature.STOP + + if self._config.get(CONF_SET_POSITION_TOPIC) is not None: + supported_features |= CoverEntityFeature.SET_POSITION + + if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None: + supported_features |= TILT_FEATURES + + self._attr_supported_features = supported_features + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics = {} @@ -506,31 +524,6 @@ class MqttCover(MqttEntity, CoverEntity): """Return current position of cover tilt.""" return self._tilt_value - @property - def device_class(self) -> CoverDeviceClass | None: - """Return the class of this sensor.""" - return self._config.get(CONF_DEVICE_CLASS) - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = CoverEntityFeature(0) - if self._config.get(CONF_COMMAND_TOPIC) is not None: - if self._config.get(CONF_PAYLOAD_OPEN) is not None: - supported_features |= CoverEntityFeature.OPEN - if self._config.get(CONF_PAYLOAD_CLOSE) is not None: - supported_features |= CoverEntityFeature.CLOSE - if self._config.get(CONF_PAYLOAD_STOP) is not None: - supported_features |= CoverEntityFeature.STOP - - if self._config.get(CONF_SET_POSITION_TOPIC) is not None: - supported_features |= CoverEntityFeature.SET_POSITION - - if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None: - supported_features |= TILT_FEATURES - - return supported_features - async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up. From 09ad1a9a3685e885fc868e0e111df2856d6f2c84 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 20:47:48 +0200 Subject: [PATCH 467/984] Remove unnecessary block use of pylint disable in components p-z (#100192) --- homeassistant/components/sensor/__init__.py | 2 +- homeassistant/components/shelly/entity.py | 4 ++-- homeassistant/helpers/script.py | 9 ++++----- homeassistant/helpers/service.py | 2 +- homeassistant/helpers/template.py | 2 +- homeassistant/runner.py | 4 ++-- homeassistant/scripts/check_config.py | 7 +++---- 7 files changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index b8151256519..6b4e4a17fc2 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -13,7 +13,7 @@ from typing import Any, Final, Self, cast, final from homeassistant.config_entries import ConfigEntry -# pylint: disable=[hass-deprecated-import] +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT, diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 1dc7573b738..69dc6cb9340 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -551,7 +551,7 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity): class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): """Represent a shelly sleeping block attribute entity.""" - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__( self, coordinator: ShellyBlockCoordinator, @@ -625,7 +625,7 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): entity_description: RpcEntityDescription - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__( self, coordinator: ShellyRpcCoordinator, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index c9d8de23b96..a1d045eb542 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -911,7 +911,7 @@ class _ScriptRun: async def _async_choose_step(self) -> None: """Choose a sequence.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access choose_data = await self._script._async_get_choose_data(self._step) with trace_path("choose"): @@ -933,7 +933,7 @@ class _ScriptRun: async def _async_if_step(self) -> None: """If sequence.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access if_data = await self._script._async_get_if_data(self._step) test_conditions = False @@ -1047,7 +1047,7 @@ class _ScriptRun: @async_trace_path("parallel") async def _async_parallel_step(self) -> None: """Run a sequence in parallel.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access scripts = await self._script._async_get_parallel_scripts(self._step) async def async_run_with_trace(idx: int, script: Script) -> None: @@ -1107,9 +1107,8 @@ class _QueuedScriptRun(_ScriptRun): await super().async_run() def _finish(self) -> None: - # pylint: disable=protected-access if self.lock_acquired: - self._script._queue_lck.release() + self._script._queue_lck.release() # pylint: disable=protected-access self.lock_acquired = False super()._finish() diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index a0fe24cb656..4532e1a00ae 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -73,7 +73,7 @@ ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache" @cache def _base_components() -> dict[str, ModuleType]: """Return a cached lookup of base components.""" - # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, calendar, diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9f280db6c98..070e5b6d9ad 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -937,7 +937,7 @@ class TemplateStateBase(State): __delitem__ = _readonly # Inheritance is done so functions that check against State keep working - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: """Initialize template state.""" self._hass = hass diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ed49db37f97..10521f80135 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -163,8 +163,7 @@ async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int: def _enable_posix_spawn() -> None: """Enable posix_spawn on Alpine Linux.""" - # pylint: disable=protected-access - if subprocess._USE_POSIX_SPAWN: + if subprocess._USE_POSIX_SPAWN: # pylint: disable=protected-access return # The subprocess module does not know about Alpine Linux/musl @@ -172,6 +171,7 @@ def _enable_posix_spawn() -> None: # less efficient. This is a workaround to force posix_spawn() # when using musl since cpython is not aware its supported. tag = next(packaging.tags.sys_tags()) + # pylint: disable-next=protected-access subprocess._USE_POSIX_SPAWN = "musllinux" in tag.platform diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 38fa9cc2463..9a63c73590b 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -30,7 +30,6 @@ import homeassistant.util.yaml.loader as yaml_loader REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) -# pylint: disable=protected-access 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), @@ -166,13 +165,13 @@ def check(config_dir, secrets=False): "secret_cache": {}, } - # pylint: disable=possibly-unused-variable + # pylint: disable-next=possibly-unused-variable def mock_load(filename, secrets=None): """Mock hass.util.load_yaml to save config file names.""" res["yaml_files"][filename] = True return MOCKS["load"][1](filename, secrets) - # pylint: disable=possibly-unused-variable + # pylint: disable-next=possibly-unused-variable def mock_secrets(ldr, node): """Mock _get_secrets.""" try: @@ -201,7 +200,7 @@ def check(config_dir, secrets=False): def secrets_proxy(*args): secrets = Secrets(*args) - res["secret_cache"] = secrets._cache + res["secret_cache"] = secrets._cache # pylint: disable=protected-access return secrets try: From 8fe5a5a398be6720377b2a6c6e755b947af06c3c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 20:48:47 +0200 Subject: [PATCH 468/984] Introduce base class for Trafikverket camera (#100114) * Introduce base class for Trafikverket camera * fix feedback * Fix feedback --- .../trafikverket_camera/binary_sensor.py | 34 +---------- .../components/trafikverket_camera/camera.py | 18 +----- .../components/trafikverket_camera/entity.py | 56 +++++++++++++++++++ .../components/trafikverket_camera/sensor.py | 36 +----------- 4 files changed, 65 insertions(+), 79 deletions(-) create mode 100644 homeassistant/components/trafikverket_camera/entity.py diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index bfbecf707bf..c9da5bd5d8a 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -10,12 +10,11 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import CameraData, TVDataUpdateCoordinator +from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 @@ -51,47 +50,20 @@ async def async_setup_entry( async_add_entities( [ TrafikverketCameraBinarySensor( - coordinator, entry.entry_id, entry.title, BINARY_SENSOR_TYPE + coordinator, entry.entry_id, BINARY_SENSOR_TYPE ) ] ) class TrafikverketCameraBinarySensor( - CoordinatorEntity[TVDataUpdateCoordinator], BinarySensorEntity + TrafikverketCameraNonCameraEntity, BinarySensorEntity ): """Representation of a Trafikverket Camera binary sensor.""" entity_description: TVCameraSensorEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: TVDataUpdateCoordinator, - entry_id: str, - name: str, - entity_description: TVCameraSensorEntityDescription, - ) -> None: - """Initiate Trafikverket Camera Binary sensor.""" - super().__init__(coordinator) - self.entity_description = entity_description - self._attr_unique_id = f"{entry_id}-{entity_description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v1.0", - name=name, - configuration_url="https://api.trafikinfo.trafikverket.se/", - ) - self._update_attr() @callback def _update_attr(self) -> None: """Update _attr.""" self._attr_is_on = self.entity_description.value_fn(self.coordinator.data) - - @callback - def _handle_coordinator_update(self) -> None: - self._update_attr() - return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index 936e460638f..a7da3db1433 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -8,12 +8,11 @@ from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCATION 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 from .const import ATTR_DESCRIPTION, ATTR_TYPE, DOMAIN from .coordinator import TVDataUpdateCoordinator +from .entity import TrafikverketCameraEntity async def async_setup_entry( @@ -29,17 +28,15 @@ async def async_setup_entry( [ TVCamera( coordinator, - entry.title, entry.entry_id, ) ], ) -class TVCamera(CoordinatorEntity[TVDataUpdateCoordinator], Camera): +class TVCamera(TrafikverketCameraEntity, Camera): """Implement Trafikverket camera.""" - _attr_has_entity_name = True _attr_name = None _attr_translation_key = "tv_camera" coordinator: TVDataUpdateCoordinator @@ -47,21 +44,12 @@ class TVCamera(CoordinatorEntity[TVDataUpdateCoordinator], Camera): def __init__( self, coordinator: TVDataUpdateCoordinator, - name: str, entry_id: str, ) -> None: """Initialize the camera.""" - super().__init__(coordinator) + super().__init__(coordinator, entry_id) Camera.__init__(self) self._attr_unique_id = entry_id - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v1.0", - name=name, - configuration_url="https://api.trafikinfo.trafikverket.se/", - ) async def async_camera_image( self, width: int | None = None, height: int | None = None diff --git a/homeassistant/components/trafikverket_camera/entity.py b/homeassistant/components/trafikverket_camera/entity.py new file mode 100644 index 00000000000..ec1d4d8f76b --- /dev/null +++ b/homeassistant/components/trafikverket_camera/entity.py @@ -0,0 +1,56 @@ +"""Base entity for Trafikverket Camera.""" +from __future__ import annotations + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TVDataUpdateCoordinator + + +class TrafikverketCameraEntity(CoordinatorEntity[TVDataUpdateCoordinator]): + """Base entity for Trafikverket Camera.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + ) -> None: + """Initiate Trafikverket Camera Sensor.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Trafikverket", + model="v1.0", + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + + +class TrafikverketCameraNonCameraEntity(TrafikverketCameraEntity): + """Base entity for Trafikverket Camera but for non camera entities.""" + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + description: EntityDescription, + ) -> None: + """Initiate Trafikverket Camera Sensor.""" + super().__init__(coordinator, entry_id) + self._attr_unique_id = f"{entry_id}-{description.key}" + self.entity_description = description + self._update_attr() + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + + @callback + def _handle_coordinator_update(self) -> None: + self._update_attr() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index eee2f353de5..96231bba755 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -13,13 +13,12 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import CameraData, TVDataUpdateCoordinator +from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 @@ -92,39 +91,15 @@ async def async_setup_entry( coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TrafikverketCameraSensor(coordinator, entry.entry_id, entry.title, description) + TrafikverketCameraSensor(coordinator, entry.entry_id, description) for description in SENSOR_TYPES ) -class TrafikverketCameraSensor( - CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity -): +class TrafikverketCameraSensor(TrafikverketCameraNonCameraEntity, SensorEntity): """Representation of a Trafikverket Camera Sensor.""" entity_description: TVCameraSensorEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: TVDataUpdateCoordinator, - entry_id: str, - name: str, - entity_description: TVCameraSensorEntityDescription, - ) -> None: - """Initiate Trafikverket Camera Sensor.""" - super().__init__(coordinator) - self.entity_description = entity_description - self._attr_unique_id = f"{entry_id}-{entity_description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v1.0", - name=name, - configuration_url="https://api.trafikinfo.trafikverket.se/", - ) - self._update_attr() @callback def _update_attr(self) -> None: @@ -132,8 +107,3 @@ class TrafikverketCameraSensor( self._attr_native_value = self.entity_description.value_fn( self.coordinator.data ) - - @callback - def _handle_coordinator_update(self) -> None: - self._update_attr() - return super()._handle_coordinator_update() From 5e2bf2b0159ada96303978718320c0722bc52f97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 13:57:57 -0500 Subject: [PATCH 469/984] Set dynalite cover device class in constructor (#100232) --- homeassistant/components/dynalite/cover.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 96a1f41f9e3..2bac51e0b8b 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum +from .bridge import DynaliteBridge from .dynalitebase import DynaliteBase, async_setup_entry_base @@ -23,7 +24,7 @@ async def async_setup_entry( """Record the async_add_entities function to add them later when received from Dynalite.""" @callback - def cover_from_device(device, bridge): + def cover_from_device(device: Any, bridge: DynaliteBridge) -> CoverEntity: if device.has_tilt: return DynaliteCoverWithTilt(device, bridge) return DynaliteCover(device, bridge) @@ -36,11 +37,11 @@ async def async_setup_entry( class DynaliteCover(DynaliteBase, CoverEntity): """Representation of a Dynalite Channel as a Home Assistant Cover.""" - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the device.""" + def __init__(self, device: Any, bridge: DynaliteBridge) -> None: + """Initialize the cover.""" + super().__init__(device, bridge) device_class = try_parse_enum(CoverDeviceClass, self._device.device_class) - return device_class or CoverDeviceClass.SHUTTER + self._attr_device_class = device_class or CoverDeviceClass.SHUTTER @property def current_cover_position(self) -> int: From 76c569c62d690db26eea27f460fdbf167e330fa7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 21:00:05 +0200 Subject: [PATCH 470/984] Clean up variables in Soundtouch (#99859) --- .../components/soundtouch/media_player.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 63e5a551745..fa5c0dd7095 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -78,21 +78,25 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_has_entity_name = True _attr_name = None + _attr_source_list = [ + Source.AUX.value, + Source.BLUETOOTH.value, + ] def __init__(self, device: SoundTouchDevice) -> None: """Create SoundTouch media player entity.""" self._device = device - self._attr_unique_id = self._device.config.device_id + self._attr_unique_id = device.config.device_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._device.config.device_id)}, + identifiers={(DOMAIN, device.config.device_id)}, connections={ - (CONNECTION_NETWORK_MAC, format_mac(self._device.config.mac_address)) + (CONNECTION_NETWORK_MAC, format_mac(device.config.mac_address)) }, manufacturer="Bose Corporation", - model=self._device.config.type, - name=self._device.config.name, + model=device.config.type, + name=device.config.name, ) self._status = None @@ -131,14 +135,6 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): """Name of the current input source.""" return self._status.source - @property - def source_list(self): - """List of available input sources.""" - return [ - Source.AUX.value, - Source.BLUETOOTH.value, - ] - @property def is_volume_muted(self): """Boolean if volume is currently muted.""" From bbcbb2e3222ee0a489b41d189473506f36fdcc12 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 21:07:32 +0200 Subject: [PATCH 471/984] Improve Entity._suggest_report_issue (#100204) --- homeassistant/helpers/entity.py | 22 ++++++++++- tests/components/sensor/test_init.py | 2 +- tests/helpers/test_entity.py | 58 +++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 99c71e2cc86..5ed16408388 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import ABC import asyncio from collections.abc import Coroutine, Iterable, Mapping, MutableMapping +from contextlib import suppress from dataclasses import dataclass from datetime import timedelta from enum import Enum, auto @@ -49,7 +50,11 @@ from homeassistant.exceptions import ( InvalidStateError, NoEntitySpecifiedError, ) -from homeassistant.loader import bind_hass +from homeassistant.loader import ( + IntegrationNotLoaded, + async_get_loaded_integration, + bind_hass, +) from homeassistant.util import ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er @@ -1215,8 +1220,21 @@ class Entity(ABC): def _suggest_report_issue(self) -> str: """Suggest to report an issue.""" report_issue = "" + + integration = None + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + if self.platform: + with suppress(IntegrationNotLoaded): + integration = async_get_loaded_integration( + self.hass, self.platform.platform_name + ) + if "custom_components" in type(self).__module__: - report_issue = "report it to the custom integration author." + if integration and integration.issue_tracker: + report_issue = f"create a bug report at {integration.issue_tracker}" + else: + report_issue = "report it to the custom integration author" else: report_issue = ( "create a bug report at " diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 1f836ad9095..b7682eb2ec2 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -164,7 +164,7 @@ async def test_deprecated_last_reset( f"with state_class {state_class} has set last_reset. Setting last_reset for " "entities with state_class other than 'total' is not supported. Please update " "your configuration if state_class is manually configured, otherwise report it " - "to the custom integration author." + "to the custom integration author" ) in caplog.text state = hass.states.get("sensor.test") diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 68eed5b6e32..61ee38a66a7 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -27,8 +27,10 @@ from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockModule, MockPlatform, get_test_home_assistant, + mock_integration, mock_registry, ) @@ -776,7 +778,7 @@ async def test_warn_slow_write_state_custom_component( assert ( "Updating state for comp_test.test_entity " "(.CustomComponentEntity'>) " - "took 10.000 seconds. Please report it to the custom integration author." + "took 10.000 seconds. Please report it to the custom integration author" ) in caplog.text @@ -1503,3 +1505,57 @@ async def test_invalid_state( ent._attr_state = "x" * 255 ent.async_write_ha_state() assert hass.states.get("test.test").state == "x" * 255 + + +async def test_suggest_report_issue_built_in( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test _suggest_report_issue for an entity from a built-in integration.""" + mock_entity = entity.Entity() + mock_entity.entity_id = "comp_test.test_entity" + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == ( + "create a bug report at https://github.com/home-assistant/core/issues" + "?q=is%3Aopen+is%3Aissue" + ) + + mock_integration(hass, MockModule(domain="test"), built_in=True) + platform = MockEntityPlatform(hass, domain="comp_test", platform_name="test") + await platform.async_add_entities([mock_entity]) + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == ( + "create a bug report at https://github.com/home-assistant/core/issues" + "?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test%22" + ) + + +async def test_suggest_report_issue_custom_component( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test _suggest_report_issue for an entity from a custom component.""" + + class CustomComponentEntity(entity.Entity): + """Custom component entity.""" + + __module__ = "custom_components.bla.sensor" + + mock_entity = CustomComponentEntity() + mock_entity.entity_id = "comp_test.test_entity" + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == "report it to the custom integration author" + + mock_integration( + hass, + MockModule( + domain="test", partial_manifest={"issue_tracker": "httpts://some_url"} + ), + built_in=False, + ) + platform = MockEntityPlatform(hass, domain="comp_test", platform_name="test") + await platform.async_add_entities([mock_entity]) + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == "create a bug report at httpts://some_url" From 368a1a944a5081130a0c95e4898631da32fddaee Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 12 Sep 2023 12:08:13 -0700 Subject: [PATCH 472/984] Remove the uniqueid from todoist (#100206) --- homeassistant/components/todoist/config_flow.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index 0a41ecb0463..6098df40ea0 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -51,8 +51,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_TOKEN]) - self._abort_if_unique_id_configured() return self.async_create_entry(title="Todoist", data=user_input) return self.async_show_form( From d417a27c85d36c52e8c8fe05e4e2816e5a18594b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 13 Sep 2023 04:08:58 +0900 Subject: [PATCH 473/984] Add meteoclimatic sensor statistics (#100186) --- homeassistant/components/meteoclimatic/sensor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index ed37c6d98ea..9a54e766945 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -3,6 +3,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,6 +30,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temp_max", @@ -47,6 +49,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="humidity_max", @@ -65,6 +68,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Pressure", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure_max", @@ -83,6 +87,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Wind Speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="wind_max", From f9ce315d1b1cf94d5dd1738d131d47fd3d7bba09 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 12 Sep 2023 19:10:40 +0000 Subject: [PATCH 474/984] Support for Insteon 4 button KeypadLink device (#100132) --- homeassistant/components/insteon/api/properties.py | 12 ++++++++++-- homeassistant/components/insteon/ipdb.py | 4 ++++ homeassistant/components/insteon/light.py | 8 ++++++++ homeassistant/components/insteon/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 6 files changed, 28 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index 7350ab14743..80a76e482e5 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -3,7 +3,12 @@ from typing import Any from pyinsteon import devices -from pyinsteon.config import RADIO_BUTTON_GROUPS, RAMP_RATE_IN_SEC, get_usable_value +from pyinsteon.config import ( + LOAD_BUTTON, + RADIO_BUTTON_GROUPS, + RAMP_RATE_IN_SEC, + get_usable_value, +) from pyinsteon.constants import ( RAMP_RATES_SEC, PropertyType, @@ -75,8 +80,11 @@ def get_schema(prop, name, groups): if name == RAMP_RATE_IN_SEC: return _list_schema(name, RAMP_RATE_LIST) if name == RADIO_BUTTON_GROUPS: - button_list = {str(group): groups[group].name for group in groups if group != 1} + button_list = {str(group): groups[group].name for group in groups} return _multi_select_schema(name, button_list) + if name == LOAD_BUTTON: + button_list = {group: groups[group].name for group in groups} + return _list_schema(name, button_list) if prop.value_type == bool: return _bool_schema(name) if prop.value_type == int: diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index de3ba7d55f2..9e9f987d611 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -10,6 +10,7 @@ from pyinsteon.device_types.ipdb import ( DimmableLightingControl_Dial, DimmableLightingControl_DinRail, DimmableLightingControl_FanLinc, + DimmableLightingControl_I3_KeypadLinc_4, DimmableLightingControl_InLineLinc01, DimmableLightingControl_InLineLinc02, DimmableLightingControl_KeypadLinc_6, @@ -55,6 +56,9 @@ DEVICE_PLATFORM: dict[Device, dict[Platform, Iterable[int]]] = { DimmableLightingControl_FanLinc: {Platform.LIGHT: [1], Platform.FAN: [2]}, DimmableLightingControl_InLineLinc01: {Platform.LIGHT: [1]}, DimmableLightingControl_InLineLinc02: {Platform.LIGHT: [1]}, + DimmableLightingControl_I3_KeypadLinc_4: { + Platform.LIGHT: [1, 2, 3, 4], + }, DimmableLightingControl_KeypadLinc_6: { Platform.LIGHT: [1], Platform.SWITCH: [3, 4, 5, 6], diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 1c12bc794f9..121d8d62c66 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -2,6 +2,7 @@ from typing import Any from pyinsteon.config import ON_LEVEL +from pyinsteon.device_types.device_base import Device as InsteonDevice from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry @@ -51,6 +52,13 @@ class InsteonDimmerEntity(InsteonEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + def __init__(self, device: InsteonDevice, group: int) -> None: + """Init the InsteonDimmerEntity entity.""" + super().__init__(device=device, group=group) + if not self._insteon_device_group.is_dimmable: + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index ad3fb7bfbe8..5fa45a16fb6 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,8 +17,8 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.4.3", - "insteon-frontend-home-assistant==0.3.5" + "pyinsteon==1.5.1", + "insteon-frontend-home-assistant==0.4.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 9b3192459aa..a1a3a598568 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1064,7 +1064,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.5 +insteon-frontend-home-assistant==0.4.0 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1750,7 +1750,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.4.3 +pyinsteon==1.5.1 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 656658f254f..4d366125d9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -832,7 +832,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.5 +insteon-frontend-home-assistant==0.4.0 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1302,7 +1302,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.4.3 +pyinsteon==1.5.1 # homeassistant.components.ipma pyipma==3.0.6 From 458a3f0df27b110a275d017d96f0c5ec9378e68a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 21:12:01 +0200 Subject: [PATCH 475/984] Remove restore functionality in Speedtest.net (#96950) --- .../components/speedtestdotnet/sensor.py | 12 +------ tests/components/speedtestdotnet/conftest.py | 6 ++-- tests/components/speedtestdotnet/test_init.py | 25 +------------- .../components/speedtestdotnet/test_sensor.py | 34 ++----------------- 4 files changed, 6 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 5bcf178f396..ccd2008503c 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -15,7 +15,6 @@ from homeassistant.const import UnitOfDataRate, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -77,10 +76,7 @@ async def async_setup_entry( ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class SpeedtestSensor( - CoordinatorEntity[SpeedTestDataCoordinator], RestoreEntity, SensorEntity -): +class SpeedtestSensor(CoordinatorEntity[SpeedTestDataCoordinator], SensorEntity): """Implementation of a speedtest.net sensor.""" entity_description: SpeedtestSensorEntityDescription @@ -134,9 +130,3 @@ class SpeedtestSensor( self._attrs[ATTR_BYTES_SENT] = self.coordinator.data[ATTR_BYTES_SENT] return self._attrs - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - if state := await self.async_get_last_state(): - self._state = state.state diff --git a/tests/components/speedtestdotnet/conftest.py b/tests/components/speedtestdotnet/conftest.py index 3324b92d8bd..0dab08eddef 100644 --- a/tests/components/speedtestdotnet/conftest.py +++ b/tests/components/speedtestdotnet/conftest.py @@ -3,14 +3,12 @@ from unittest.mock import patch import pytest -from . import MOCK_RESULTS, MOCK_SERVERS +from . import MOCK_SERVERS -@pytest.fixture(autouse=True) +@pytest.fixture def mock_api(): """Mock entry setup.""" with patch("speedtest.Speedtest") as mock_api: mock_api.return_value.get_servers.return_value = MOCK_SERVERS - mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] - mock_api.return_value.results.dict.return_value = MOCK_RESULTS yield mock_api diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index da19fd85dd3..c6804f48401 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -5,11 +5,7 @@ from unittest.mock import MagicMock import speedtest -from homeassistant.components.speedtestdotnet.const import ( - CONF_SERVER_ID, - CONF_SERVER_NAME, - DOMAIN, -) +from homeassistant.components.speedtestdotnet.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -18,25 +14,6 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -async def test_successful_config_entry(hass: HomeAssistant) -> None: - """Test that SpeedTestDotNet is configured successfully.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", - CONF_SERVER_ID: "1", - }, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - - assert entry.state == ConfigEntryState.LOADED - assert hass.data[DOMAIN] - - async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test SpeedTestDotNet failed due to an error.""" diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index 887f0ba0491..d15e9fb92f4 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -3,11 +3,11 @@ from unittest.mock import MagicMock from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.speedtestdotnet import DOMAIN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from . import MOCK_RESULTS, MOCK_SERVERS, MOCK_STATES -from tests.common import MockConfigEntry, mock_restore_cache +from tests.common import MockConfigEntry async def test_speedtestdotnet_sensors( @@ -36,33 +36,3 @@ async def test_speedtestdotnet_sensors( sensor = hass.states.get("sensor.speedtest_ping") assert sensor assert sensor.state == MOCK_STATES["ping"] - - -async def test_restore_last_state(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test restoring last state for sensors.""" - mock_restore_cache( - hass, - [ - State(f"sensor.speedtest_{sensor}", state) - for sensor, state in MOCK_STATES.items() - ], - ) - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - - sensor = hass.states.get("sensor.speedtest_ping") - assert sensor - assert sensor.state == MOCK_STATES["ping"] - - sensor = hass.states.get("sensor.speedtest_download") - assert sensor - assert sensor.state == MOCK_STATES["download"] - - sensor = hass.states.get("sensor.speedtest_ping") - assert sensor - assert sensor.state == MOCK_STATES["ping"] From eb0ab3de93bfb60dc8220ae136e7d9395ec1c458 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 21:28:29 +0200 Subject: [PATCH 476/984] User shorthand attr for mqtt alarm_control_panel (#100234) --- .../components/mqtt/alarm_control_panel.py | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index a0939fdc615..4639bd82eb3 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -161,8 +161,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): discovery_data: DiscoveryInfoType | None, ) -> None: """Init the MQTT Alarm Control Panel.""" - self._state: str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod @@ -183,6 +181,16 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): for feature in self._config[CONF_SUPPORTED_FEATURES]: self._attr_supported_features |= _SUPPORTED_FEATURES[feature] + if (code := self._config.get(CONF_CODE)) is None: + self._attr_code_format = None + elif code == REMOTE_CODE or ( + isinstance(code, str) and re.search("^\\d+$", code) + ): + self._attr_code_format = alarm.CodeFormat.NUMBER + else: + self._attr_code_format = alarm.CodeFormat.TEXT + self._attr_code_arm_required = bool(self._config[CONF_CODE_ARM_REQUIRED]) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -205,7 +213,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): ): _LOGGER.warning("Received unexpected payload: %s", msg.payload) return - self._state = str(payload) + self._attr_state = str(payload) get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self._sub_state = subscription.async_prepare_subscribe_topics( @@ -225,26 +233,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - - @property - def code_format(self) -> alarm.CodeFormat | None: - """Return one or more digits/characters.""" - code: str | None - if (code := self._config.get(CONF_CODE)) is None: - return None - if code == REMOTE_CODE or (isinstance(code, str) and re.search("^\\d+$", code)): - return alarm.CodeFormat.NUMBER - return alarm.CodeFormat.TEXT - - @property - def code_arm_required(self) -> bool: - """Whether the code is required for arm actions.""" - return bool(self._config[CONF_CODE_ARM_REQUIRED]) - async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command. From e3837cd1e00424c738597a74ff47be7acd20fb15 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 22:21:13 +0200 Subject: [PATCH 477/984] Use shorthand attr for mqtt assumed_state (#100241) --- homeassistant/components/mqtt/cover.py | 6 +----- homeassistant/components/mqtt/fan.py | 6 +----- homeassistant/components/mqtt/humidifier.py | 6 +----- homeassistant/components/mqtt/lawn_mower.py | 16 ++++++---------- .../components/mqtt/light/schema_basic.py | 6 +----- .../components/mqtt/light/schema_json.py | 6 +----- .../components/mqtt/light/schema_template.py | 6 +----- homeassistant/components/mqtt/lock.py | 6 +----- homeassistant/components/mqtt/number.py | 13 ++++--------- homeassistant/components/mqtt/select.py | 15 ++++++--------- homeassistant/components/mqtt/siren.py | 6 +----- homeassistant/components/mqtt/switch.py | 6 +----- homeassistant/components/mqtt/text.py | 6 +----- 13 files changed, 26 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index ae22eb675ac..3044e2d6396 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -294,6 +294,7 @@ class MqttCover(MqttEntity, CoverEntity): ): # Force into optimistic mode. self._optimistic = True + self._attr_assumed_state = bool(self._optimistic) if ( config[CONF_TILT_STATE_OPTIMISTIC] @@ -488,11 +489,6 @@ class MqttCover(MqttEntity, CoverEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return bool(self._optimistic) - @property def is_closed(self) -> bool | None: """Return true if the cover is closed or None if the status is unknown.""" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 58189c3cb3e..5c7557c7598 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -295,6 +295,7 @@ class MqttFan(MqttEntity, FanEntity): optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_direction = ( optimistic or self._topic[CONF_DIRECTION_STATE_TOPIC] is None ) @@ -491,11 +492,6 @@ class MqttFan(MqttEntity, FanEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def is_on(self) -> bool | None: """Return true if device is on.""" diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index aebb05c19f7..52d8db3fc98 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -260,6 +260,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_target_humidity = ( optimistic or self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC] is None ) @@ -465,11 +466,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the entity. diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 44db3581f8b..fc3996ffbff 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -113,7 +113,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] _command_topics: dict[str, str] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - _optimistic: bool = False def __init__( self, @@ -134,7 +133,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._value_template = MqttValueTemplate( config.get(CONF_ACTIVITY_VALUE_TEMPLATE), entity=self @@ -198,7 +197,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -217,19 +216,16 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and (last_state := await self.async_get_last_state()): + if self._attr_assumed_state and ( + last_state := await self.async_get_last_state() + ): with contextlib.suppress(ValueError): self._attr_activity = LawnMowerActivity(last_state.state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def _async_operate(self, option: str, activity: LawnMowerActivity) -> None: """Execute operation.""" payload = self._command_templates[option](option) - if self._optimistic: + if self._attr_assumed_state: self._attr_activity = activity self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 2a726075bb0..34b4a567ba5 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -330,6 +330,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): optimistic or topic[CONF_COLOR_MODE_STATE_TOPIC] is None ) self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_rgb_color = optimistic or topic[CONF_RGB_STATE_TOPIC] is None self._optimistic_rgbw_color = optimistic or topic[CONF_RGBW_STATE_TOPIC] is None self._optimistic_rgbww_color = ( @@ -668,11 +669,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): restore_state(ATTR_XY_COLOR) restore_state(ATTR_HS_COLOR, ATTR_XY_COLOR) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 """Turn the device on. diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index b7787912161..11574b88798 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -215,6 +215,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): } optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._flash_times = { key: config.get(key) @@ -462,11 +463,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): ) self._attr_xy_color = last_attributes.get(ATTR_XY_COLOR, self.xy_color) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def color_mode(self) -> ColorMode | str | None: """Return current color mode.""" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 98ee7648eeb..e811c45fc67 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -179,6 +179,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): or self._topics[CONF_STATE_TOPIC] is None or CONF_STATE_TEMPLATE not in self._config ) + self._attr_assumed_state = bool(self._optimistic) color_modes = {ColorMode.ONOFF} if CONF_BRIGHTNESS_TEMPLATE in config: @@ -315,11 +316,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if last_state.attributes.get(ATTR_EFFECT): self._attr_effect = last_state.attributes.get(ATTR_EFFECT) - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on. diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index cb586c06309..d2e67ba40da 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -159,6 +159,7 @@ class MqttLock(MqttEntity, LockEntity): self._optimistic = ( config[CONF_OPTIMISTIC] or self._config.get(CONF_STATE_TOPIC) is None ) + self._attr_assumed_state = bool(self._optimistic) self._compiled_pattern = config.get(CONF_CODE_FORMAT) self._attr_code_format = ( @@ -221,11 +222,6 @@ class MqttLock(MqttEntity, LockEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_lock(self, **kwargs: Any) -> None: """Lock the device. diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 971b44b43bf..a88210a3198 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -161,7 +161,7 @@ class MqttNumber(MqttEntity, RestoreNumber): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" self._config = config - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._command_template = MqttCommandTemplate( config.get(CONF_COMMAND_TEMPLATE), entity=self @@ -218,7 +218,7 @@ class MqttNumber(MqttEntity, RestoreNumber): if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -237,7 +237,7 @@ class MqttNumber(MqttEntity, RestoreNumber): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and ( + if self._attr_assumed_state and ( last_number_data := await self.async_get_last_number_data() ): self._attr_native_value = last_number_data.native_value @@ -250,7 +250,7 @@ class MqttNumber(MqttEntity, RestoreNumber): current_number = int(value) payload = self._command_template(current_number) - if self._optimistic: + if self._attr_assumed_state: self._attr_native_value = current_number self.async_write_ha_state() @@ -261,8 +261,3 @@ class MqttNumber(MqttEntity, RestoreNumber): self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index df8cf024bd2..1c4b33de0ee 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -115,7 +115,7 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._attr_options = config[CONF_OPTIONS] self._command_template = MqttCommandTemplate( @@ -152,7 +152,7 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -171,13 +171,15 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and (last_state := await self.async_get_last_state()): + if self._attr_assumed_state and ( + last_state := await self.async_get_last_state() + ): self._attr_current_option = last_state.state async def async_select_option(self, option: str) -> None: """Update the current value.""" payload = self._command_template(option) - if self._optimistic: + if self._attr_assumed_state: self._attr_current_option = option self.async_write_ha_state() @@ -188,8 +190,3 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 328812a6e49..aeabd0fe148 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -194,6 +194,7 @@ class MqttSiren(MqttEntity, SirenEntity): self._attr_supported_features = _supported_features self._optimistic = config[CONF_OPTIMISTIC] or CONF_STATE_TOPIC not in config + self._attr_assumed_state = bool(self._optimistic) self._attr_is_on = False if self._optimistic else None command_template: Template | None = config.get(CONF_COMMAND_TEMPLATE) @@ -301,11 +302,6 @@ class MqttSiren(MqttEntity, SirenEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 107b0b1cb10..e8872d3f0d1 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -125,6 +125,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): self._optimistic = ( config[CONF_OPTIMISTIC] or config.get(CONF_STATE_TOPIC) is None ) + self._attr_assumed_state = bool(self._optimistic) self._value_template = MqttValueTemplate( self._config.get(CONF_VALUE_TEMPLATE), entity=self @@ -171,11 +172,6 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): if self._optimistic and (last_state := await self.async_get_last_state()): self._attr_is_on = last_state.state == STATE_ON - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on. diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 13677b7f35b..6d1196cfd95 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -169,6 +169,7 @@ class MqttTextEntity(MqttEntity, TextEntity): ).async_render_with_possible_json_value optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -203,11 +204,6 @@ class MqttTextEntity(MqttEntity, TextEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_set_value(self, value: str) -> None: """Change the text.""" payload = self._command_template(value) From f2fac40019bc8b3da599cd023f506e03b252d159 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Tue, 12 Sep 2023 22:21:58 +0200 Subject: [PATCH 478/984] Add strict typing to GPSD (#100030) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .strict-typing | 1 + homeassistant/components/gpsd/sensor.py | 21 ++++++++++++++------- mypy.ini | 10 ++++++++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index 852ebbc0420..c1138119f5f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -141,6 +141,7 @@ homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_sheets.* +homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 28ca9d3f075..3e356f1509c 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import socket +from typing import Any from gps3.agps3threaded import AGPS3mechanism import voluptuous as vol @@ -48,9 +49,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the GPSD component.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] # Will hopefully be possible with the next gps3 update # https://github.com/wadda/gps3/issues/11 @@ -77,7 +78,13 @@ def setup_platform( class GpsdSensor(SensorEntity): """Representation of a GPS receiver available via GPSD.""" - def __init__(self, hass, name, host, port): + def __init__( + self, + hass: HomeAssistant, + name: str, + host: str, + port: int, + ) -> None: """Initialize the GPSD sensor.""" self.hass = hass self._name = name @@ -89,12 +96,12 @@ class GpsdSensor(SensorEntity): self.agps_thread.run_thread() @property - def name(self): + def name(self) -> str: """Return the name.""" return self._name @property - def native_value(self): + def native_value(self) -> str | None: """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: return "3D Fix" @@ -103,7 +110,7 @@ class GpsdSensor(SensorEntity): return None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the GPS.""" return { ATTR_LATITUDE: self.agps_thread.data_stream.lat, diff --git a/mypy.ini b/mypy.ini index 6bade2728f4..3d6e4e1b2b6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1172,6 +1172,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gpsd.*] +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.greeneye_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true From fa0b999d08def7725bed48c259234b100bc4e7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 12 Sep 2023 23:22:10 +0300 Subject: [PATCH 479/984] Upgrade ruff to 0.0.289 (#100238) --- .pre-commit-config.yaml | 2 +- homeassistant/components/dynalite/dynalitebase.py | 2 +- homeassistant/components/ios/sensor.py | 2 +- homeassistant/components/websocket_api/sensor.py | 2 +- pyproject.toml | 1 + requirements_test_pre_commit.txt | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1a38238e159..b5fafdd6dab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.285 + rev: v0.0.289 hooks: - id: ruff args: diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 43a4a5b106b..baf4c12a4c5 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -70,7 +70,7 @@ class DynaliteBase(RestoreEntity, ABC): ) async def async_added_to_hass(self) -> None: - """Added to hass so need to restore state and register to dispatch.""" + """Handle addition to hass: restore state and register to dispatch.""" # register for device specific update await super().async_added_to_hass() diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 45cd3586af2..610cea8c814 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -137,7 +137,7 @@ class IOSSensor(SensorEntity): self.async_write_ha_state() async def async_added_to_hass(self) -> None: - """Added to hass so need to register to dispatch.""" + """Handle addition to hass: register to dispatch.""" self._attr_native_value = self._device[ios.ATTR_BATTERY][ self.entity_description.key ] diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index 9377fcefd92..5857ead2c11 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -34,7 +34,7 @@ class APICount(SensorEntity): self.count = 0 async def async_added_to_hass(self) -> None: - """Added to hass.""" + """Handle addition to hass.""" self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_WEBSOCKET_CONNECTED, self._update_count diff --git a/pyproject.toml b/pyproject.toml index 73f47998ea7..7bab1c1b122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -289,6 +289,7 @@ disable = [ "use-list-literal", # C405 "useless-object-inheritance", # UP004 "useless-return", # PLR1711 + "no-self-use", # PLR6301 # Handled by mypy # Ref: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 98c8f40b82b..dadc3e0cab2 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -2,5 +2,5 @@ black==23.9.1 codespell==2.2.2 -ruff==0.0.285 +ruff==0.0.289 yamllint==1.32.0 From 1b40a56e2b9ae204b400d100a03757a31cfbefae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 15:24:38 -0500 Subject: [PATCH 480/984] Update ecobee zeroconf/homekit discovery (#100091) --- homeassistant/components/ecobee/manifest.json | 5 ++++- homeassistant/generated/zeroconf.py | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 43f22e2b4d6..71f5e04f75a 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -5,12 +5,15 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "homekit": { - "models": ["EB-*", "ecobee*"] + "models": ["EB", "ecobee*"] }, "iot_class": "cloud_polling", "loggers": ["pyecobee"], "requirements": ["python-ecobee-api==0.2.14"], "zeroconf": [ + { + "type": "_ecobee._tcp.local." + }, { "type": "_sideplay._tcp.local.", "properties": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3874a06ab4b..36ddfd68479 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -44,7 +44,7 @@ HOMEKIT = { "always_discover": True, "domain": "roku", }, - "EB-*": { + "EB": { "always_discover": True, "domain": "ecobee", }, @@ -386,6 +386,11 @@ ZEROCONF = { "name": "wac*", }, ], + "_ecobee._tcp.local.": [ + { + "domain": "ecobee", + }, + ], "_elg._tcp.local.": [ { "domain": "elgato", From 904913c1a6b0a89a979e97e041adeab8451d2a57 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 22:37:51 +0200 Subject: [PATCH 481/984] Use shorthand attributes in VLC telnet (#99916) * Use shorthand attributes in VLC telnet * Apply suggestions from code review Co-authored-by: Martin Hjelmare * fix mypy * Attempt 3 --------- Co-authored-by: Martin Hjelmare --- .../components/vlc_telnet/media_player.py | 103 +++++------------- 1 file changed, 29 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 87bc158331e..ef1df676a2d 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime from functools import wraps from typing import Any, Concatenate, ParamSpec, TypeVar @@ -59,9 +58,9 @@ def catch_vlc_errors( LOGGER.error("Command error: %s", err) except ConnectError as err: # pylint: disable=protected-access - if self._available: + if self._attr_available: LOGGER.error("Connection error: %s", err) - self._available = False + self._attr_available = False return wrapper @@ -86,22 +85,16 @@ class VlcDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.BROWSE_MEDIA ) + _volume_bkp = 0.0 + volume_level: int def __init__( self, config_entry: ConfigEntry, vlc: Client, name: str, available: bool ) -> None: """Initialize the vlc device.""" self._config_entry = config_entry - self._volume: float | None = None - self._muted: bool | None = None - self._media_position_updated_at: datetime | None = None - self._media_position: int | None = None - self._media_duration: int | None = None self._vlc = vlc - self._available = available - self._volume_bkp = 0.0 - self._media_artist: str | None = None - self._media_title: str | None = None + self._attr_available = available config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry_id self._attr_device_info = DeviceInfo( @@ -115,7 +108,7 @@ class VlcDevice(MediaPlayerEntity): @catch_vlc_errors async def async_update(self) -> None: """Get the latest details from the device.""" - if not self._available: + if not self.available: try: await self._vlc.connect() except ConnectError as err: @@ -132,13 +125,13 @@ class VlcDevice(MediaPlayerEntity): return self._attr_state = MediaPlayerState.IDLE - self._available = True + self._attr_available = True LOGGER.info("Connected to vlc host: %s", self._vlc.host) status = await self._vlc.status() LOGGER.debug("Status: %s", status) - self._volume = status.audio_volume / MAX_VOLUME + self._attr_volume_level = status.audio_volume / MAX_VOLUME state = status.state if state == "playing": self._attr_state = MediaPlayerState.PLAYING @@ -148,80 +141,42 @@ class VlcDevice(MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE if self._attr_state != MediaPlayerState.IDLE: - self._media_duration = (await self._vlc.get_length()).length + self._attr_media_duration = (await self._vlc.get_length()).length time_output = await self._vlc.get_time() vlc_position = time_output.time # Check if current position is stale. - if vlc_position != self._media_position: - self._media_position_updated_at = dt_util.utcnow() - self._media_position = vlc_position + if vlc_position != self.media_position: + self._attr_media_position_updated_at = dt_util.utcnow() + self._attr_media_position = vlc_position info = await self._vlc.info() data = info.data LOGGER.debug("Info data: %s", data) self._attr_media_album_name = data.get("data", {}).get("album") - self._media_artist = data.get("data", {}).get("artist") - self._media_title = data.get("data", {}).get("title") + self._attr_media_artist = data.get("data", {}).get("artist") + self._attr_media_title = data.get("data", {}).get("title") now_playing = data.get("data", {}).get("now_playing") # Many radio streams put artist/title/album in now_playing and title is the station name. if now_playing: - if not self._media_artist: - self._media_artist = self._media_title - self._media_title = now_playing + if not self.media_artist: + self._attr_media_artist = self._attr_media_title + self._attr_media_title = now_playing - if self._media_title: + if self.media_title: return # Fall back to filename. if data_info := data.get("data"): - self._media_title = data_info["filename"] + self._attr_media_title = data_info["filename"] # Strip out auth signatures if streaming local media - if self._media_title and (pos := self._media_title.find("?authSig=")) != -1: - self._media_title = self._media_title[:pos] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def volume_level(self) -> float | None: - """Volume level of the media player (0..1).""" - return self._volume - - @property - def is_volume_muted(self) -> bool | None: - """Boolean if volume is currently muted.""" - return self._muted - - @property - def media_duration(self) -> int | None: - """Duration of current playing media in seconds.""" - return self._media_duration - - @property - def media_position(self) -> int | None: - """Position of current playing media in seconds.""" - return self._media_position - - @property - def media_position_updated_at(self) -> datetime | None: - """When was the position of the current playing media valid.""" - return self._media_position_updated_at - - @property - def media_title(self) -> str | None: - """Title of current playing media.""" - return self._media_title - - @property - def media_artist(self) -> str | None: - """Artist of current playing media, music track only.""" - return self._media_artist + if (media_title := self.media_title) and ( + pos := media_title.find("?authSig=") + ) != -1: + self._attr_media_title = media_title[:pos] @catch_vlc_errors async def async_media_seek(self, position: float) -> None: @@ -231,24 +186,24 @@ class VlcDevice(MediaPlayerEntity): @catch_vlc_errors async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - assert self._volume is not None + assert self._attr_volume_level is not None if mute: - self._volume_bkp = self._volume + self._volume_bkp = self._attr_volume_level await self.async_set_volume_level(0) else: await self.async_set_volume_level(self._volume_bkp) - self._muted = mute + self._attr_is_volume_muted = mute @catch_vlc_errors async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._vlc.set_volume(round(volume * MAX_VOLUME)) - self._volume = volume + self._attr_volume_level = volume - if self._muted and self._volume > 0: + if self.is_volume_muted and self.volume_level > 0: # This can happen if we were muted and then see a volume_up. - self._muted = False + self._attr_is_volume_muted = False @catch_vlc_errors async def async_media_play(self) -> None: From fc75172d79d3ad5d3cbcd2825ebbc1ea73937506 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 16:35:39 -0500 Subject: [PATCH 482/984] Bump async-upnp-client to 0.35.1 (#100248) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 23c45b73ec5..53bda449465 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.35.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.35.1", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 2adb2e76347..d7a72a53411 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.35.0"], + "requirements": ["async-upnp-client==0.35.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 9461eb86af6..be75e3f4465 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.35.0" + "async-upnp-client==0.35.1" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index a6eb95933b4..c9cf452bac2 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.35.0"] + "requirements": ["async-upnp-client==0.35.1"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 95bb3e77966..e42235af747 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.35.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.35.1", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 993cc6ca4fa..e510a58b3e7 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.35.0"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.35.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a5fb3856c05..bd6a130a2fc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiohttp-cors==0.7.0 aiohttp==3.8.5 astral==2.2 async-timeout==4.0.3 -async-upnp-client==0.35.0 +async-upnp-client==0.35.1 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==22.9.0 diff --git a/requirements_all.txt b/requirements_all.txt index a1a3a598568..72cc1d9479e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -456,7 +456,7 @@ asterisk-mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.0 +async-upnp-client==0.35.1 # homeassistant.components.esphome async_interrupt==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4d366125d9f..0e026726b6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -410,7 +410,7 @@ arcam-fmj==1.4.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.0 +async-upnp-client==0.35.1 # homeassistant.components.esphome async_interrupt==1.1.1 From 8aa689ebae92a0a5d66a3a0f84f03881d8dda62a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 12 Sep 2023 23:36:44 +0200 Subject: [PATCH 483/984] Bump pynetgear to 0.10.10 (#100242) --- homeassistant/components/netgear/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index be4dd0f2d9d..59a41542d7c 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/netgear", "iot_class": "local_polling", "loggers": ["pynetgear"], - "requirements": ["pynetgear==0.10.9"], + "requirements": ["pynetgear==0.10.10"], "ssdp": [ { "manufacturer": "NETGEAR, Inc.", diff --git a/requirements_all.txt b/requirements_all.txt index 72cc1d9479e..23e58f00b19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1870,7 +1870,7 @@ pymyq==3.1.4 pymysensors==0.24.0 # homeassistant.components.netgear -pynetgear==0.10.9 +pynetgear==0.10.10 # homeassistant.components.netio pynetio==0.1.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e026726b6a..8a8655d2d66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1392,7 +1392,7 @@ pymyq==3.1.4 pymysensors==0.24.0 # homeassistant.components.netgear -pynetgear==0.10.9 +pynetgear==0.10.10 # homeassistant.components.nobo_hub pynobo==1.6.0 From bbcae19d0e99102b7f5b582c96c966ed71994e65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 17:15:13 -0500 Subject: [PATCH 484/984] Disable always responding to all SSDP M-SEARCH requests with the root device (#100224) --- homeassistant/components/ssdp/__init__.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index aaffc5a157a..ded663af897 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -23,13 +23,7 @@ from async_upnp_client.const import ( SsdpSource, ) from async_upnp_client.description_cache import DescriptionCache -from async_upnp_client.server import ( - SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE, - SSDP_SEARCH_RESPONDER_OPTIONS, - UpnpServer, - UpnpServerDevice, - UpnpServerService, -) +from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService from async_upnp_client.ssdp import ( SSDP_PORT, determine_source_target, @@ -796,11 +790,6 @@ class Server: http_port=http_port, server_device=HassUpnpServiceDevice, boot_id=boot_id, - options={ - SSDP_SEARCH_RESPONDER_OPTIONS: { - SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE: True - } - }, ) ) results = await asyncio.gather( From f344000ef9b60825bf33371da2115e8d65ecc0c7 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 12 Sep 2023 18:17:29 -0400 Subject: [PATCH 485/984] Bump pyenphase to 1.11.2 (#100249) * Bump pyenphase to 1.11.1 * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- 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 c6d127a3f6e..9fc6b63edfc 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.11.0"], + "requirements": ["pyenphase==1.11.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 23e58f00b19..573bdc2e5f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1675,7 +1675,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.0 +pyenphase==1.11.2 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a8655d2d66..b451f480e2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1245,7 +1245,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.0 +pyenphase==1.11.2 # homeassistant.components.everlights pyeverlights==0.1.0 From 5272387bd311d7a6b9895ebf2664d6776fadf495 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 12 Sep 2023 19:16:31 -0400 Subject: [PATCH 486/984] Fix incorrect off peak translation key for Roborock (#100246) fix incorrect translation key --- homeassistant/components/roborock/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 0170c8ac706..92d53c2e6bd 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -192,10 +192,10 @@ "dnd_end_time": { "name": "Do not disturb end" }, - "off_peak_start_time": { + "off_peak_start": { "name": "Off-peak start" }, - "off_peak_end_time": { + "off_peak_end": { "name": "Off-peak end" } }, From fe85b20502dff82ead74dcf1d93390b8927b5cfb Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 13 Sep 2023 01:24:49 +0200 Subject: [PATCH 487/984] SamsungTV: Add unique_id for when missing (legacy models) (#96829) * Add unique_id for when missing (legacy models) * add comment * update tests, thx @epenet --- homeassistant/components/samsungtv/entity.py | 3 +- .../samsungtv/snapshots/test_init.ambr | 55 +++++++++++++++++++ tests/components/samsungtv/test_init.py | 13 ++++- 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 tests/components/samsungtv/snapshots/test_init.ambr diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index e0ecbaac024..2b6373efc24 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -21,7 +21,8 @@ class SamsungTVEntity(Entity): self._bridge = bridge self._mac = config_entry.data.get(CONF_MAC) self._attr_name = config_entry.data.get(CONF_NAME) - self._attr_unique_id = config_entry.unique_id + # Fallback for legacy models that doesn't have a API to retrieve MAC or SerialNumber + self._attr_unique_id = config_entry.unique_id or config_entry.entry_id self._attr_device_info = DeviceInfo( # Instead of setting the device name to the entity name, samsungtv # should be updated to set has_entity_name = True diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr new file mode 100644 index 00000000000..f8b11bd864a --- /dev/null +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_setup_updates_from_ssdp + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'any', + 'is_volume_muted': False, + 'source_list': list([ + 'TV', + 'HDMI', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.any', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_updates_from_ssdp.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'TV', + 'HDMI', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.any', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'any', + 'platform': 'samsungtv', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'sample-entry-id', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 7491f3b76b7..526f7a12fed 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import DOMAIN, MediaPlayerEntityFeature from homeassistant.components.samsungtv.const import ( @@ -30,6 +31,7 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_samsungtv_entry from .const import ( @@ -115,9 +117,13 @@ async def test_setup_h_j_model( @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_updates_from_ssdp(hass: HomeAssistant) -> None: +async def test_setup_updates_from_ssdp( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setting up the entry fetches data from ssdp cache.""" - entry = MockConfigEntry(domain="samsungtv", data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry( + domain="samsungtv", data=MOCK_ENTRYDATA_WS, entry_id="sample-entry-id" + ) entry.add_to_hass(hass) async def _mock_async_get_discovery_info_by_st(hass: HomeAssistant, mock_st: str): @@ -135,7 +141,8 @@ async def test_setup_updates_from_ssdp(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - assert hass.states.get("media_player.any") + assert hass.states.get("media_player.any") == snapshot + assert entity_registry.async_get("media_player.any") == snapshot assert ( entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "https://fake_host:12345/tv_agent" From 2518fbc9734aaf91ae5c40b86158e6b40ff38952 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Sep 2023 01:41:50 +0200 Subject: [PATCH 488/984] Update jsonpath to 0.82.2 (#100252) --- homeassistant/components/rest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c8796c7161c..d638c20d2a4 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/rest", "iot_class": "local_polling", - "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"] + "requirements": ["jsonpath==0.82.2", "xmltodict==0.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 573bdc2e5f1..d63abfddcb7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1085,7 +1085,7 @@ jaraco.abode==3.3.0 jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest -jsonpath==0.82 +jsonpath==0.82.2 # homeassistant.components.justnimbus justnimbus==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b451f480e2a..80877b96faa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -850,7 +850,7 @@ jaraco.abode==3.3.0 jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest -jsonpath==0.82 +jsonpath==0.82.2 # homeassistant.components.justnimbus justnimbus==0.6.0 From f5aa2559d7dc3b7cf2e0f61f470ad9ee33a461da Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Sep 2023 08:14:01 +0200 Subject: [PATCH 489/984] Fix pylint config warning (#100251) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7bab1c1b122..7bc3edc9bf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -289,7 +289,7 @@ disable = [ "use-list-literal", # C405 "useless-object-inheritance", # UP004 "useless-return", # PLR1711 - "no-self-use", # PLR6301 + # "no-self-use", # PLR6301 # Optional plugin, not enabled # Handled by mypy # Ref: From 270df003fe4ed2a013150bc6a41076a2d2eb0797 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 13 Sep 2023 02:15:33 -0400 Subject: [PATCH 490/984] Bump pyenphase to 1.11.3 (#100255) --- 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 9fc6b63edfc..aa801fea14e 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.11.2"], + "requirements": ["pyenphase==1.11.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index d63abfddcb7..379cb8e5679 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1675,7 +1675,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.2 +pyenphase==1.11.3 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80877b96faa..6b4f12206f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1245,7 +1245,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.2 +pyenphase==1.11.3 # homeassistant.components.everlights pyeverlights==0.1.0 From 756f542ac62cf6621eba787e08059cb6275e8e38 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 12 Sep 2023 23:18:07 -0700 Subject: [PATCH 491/984] Update apple_weatherkit to 1.0.2 (#100254) --- 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 984e36483c7..1e8bb8ba5c5 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.1"] + "requirements": ["apple_weatherkit==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 379cb8e5679..f591dfb559a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -424,7 +424,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.1 +apple_weatherkit==1.0.2 # homeassistant.components.apprise apprise==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b4f12206f6..34d559520ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -390,7 +390,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.1 +apple_weatherkit==1.0.2 # homeassistant.components.apprise apprise==1.4.5 From 09f58ec396672fddd37deea11647bd283b3bc66f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 13 Sep 2023 02:33:48 -0400 Subject: [PATCH 492/984] Bump python-roborock to 0.34.0 (#100236) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/snapshots/test_diagnostics.ambr | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index dfcac67d2b0..81bbd07d904 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.33.2"] + "requirements": ["python-roborock==0.34.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f591dfb559a..42eaf990045 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2163,7 +2163,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.33.2 +python-roborock==0.34.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34d559520ce..17a9d08d37c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.33.2 +python-roborock==0.34.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index eb70e04110f..a766a6c2703 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -225,11 +225,13 @@ 'area': 20965000, 'avoidCount': 19, 'begin': 1672543330, + 'beginDatetime': '2023-01-01T03:22:10+00:00', 'cleanType': 3, 'complete': 1, 'duration': 1176, 'dustCollectionStatus': 1, 'end': 1672544638, + 'endDatetime': '2023-01-01T03:43:58+00:00', 'error': 0, 'finishReason': 56, 'mapFlag': 0, From e87603aa5942597f5199eb891a1443f9da23e8f1 Mon Sep 17 00:00:00 2001 From: John Hollowell Date: Wed, 13 Sep 2023 02:35:59 -0400 Subject: [PATCH 493/984] Correct Venstar firmware version to use device's FW version instead of API version (#98493) --- CODEOWNERS | 4 ++-- homeassistant/components/venstar/__init__.py | 2 +- homeassistant/components/venstar/manifest.json | 2 +- tests/components/venstar/__init__.py | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9771a9e25e5..bba1c2debbf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1361,8 +1361,8 @@ build.json @home-assistant/supervisor /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 -/homeassistant/components/venstar/ @garbled1 -/tests/components/venstar/ @garbled1 +/homeassistant/components/venstar/ @garbled1 @jhollowe +/tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/verisure/ @frenck /tests/components/verisure/ @frenck /homeassistant/components/versasense/ @imstevenxyz diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index a92d495f6af..1416bcf376a 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -153,5 +153,5 @@ class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): name=self._client.name, manufacturer="Venstar", model=f"{self._client.model}-{self._client.get_type()}", - sw_version=self._client.get_api_ver(), + sw_version="{}.{}".format(*(self._client.get_firmware_ver())), ) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index 39cbe0d3529..f3045fe49e8 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -1,7 +1,7 @@ { "domain": "venstar", "name": "Venstar", - "codeowners": ["@garbled1"], + "codeowners": ["@garbled1", "@jhollowe"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", "iot_class": "local_polling", diff --git a/tests/components/venstar/__init__.py b/tests/components/venstar/__init__.py index fa35dd88379..f91f8f28bdf 100644 --- a/tests/components/venstar/__init__.py +++ b/tests/components/venstar/__init__.py @@ -18,7 +18,8 @@ class VenstarColorTouchMock: """Initialize the Venstar library.""" self.status = {} self.model = "COLORTOUCH" - self._api_ver = 5 + self._api_ver = 7 + self._firmware_ver = tuple(5, 28) self.name = "TestVenstar" self._info = {} self._sensors = {} From dd95b51d108211b60524c6d245030099c01a8b58 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Wed, 13 Sep 2023 00:22:58 -0700 Subject: [PATCH 494/984] Address weatherkit late review comments (#100265) * Address review comments from original weatherkit PR * Use .get() for optional fields --- .../components/weatherkit/config_flow.py | 2 +- homeassistant/components/weatherkit/const.py | 5 +- .../components/weatherkit/weather.py | 125 ++++++++++-------- .../components/weatherkit/test_config_flow.py | 40 +++--- tests/components/weatherkit/test_setup.py | 10 +- 5 files changed, 95 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/weatherkit/config_flow.py b/homeassistant/components/weatherkit/config_flow.py index d9db70dde11..5762c4ae9b2 100644 --- a/homeassistant/components/weatherkit/config_flow.py +++ b/homeassistant/components/weatherkit/config_flow.py @@ -120,7 +120,7 @@ class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): location[CONF_LONGITUDE], ) - if len(availability) == 0: + if not availability: raise WeatherKitUnsupportedLocationError( "API does not support this location" ) diff --git a/homeassistant/components/weatherkit/const.py b/homeassistant/components/weatherkit/const.py index f2ef7e4c720..590ca65c9a9 100644 --- a/homeassistant/components/weatherkit/const.py +++ b/homeassistant/components/weatherkit/const.py @@ -5,7 +5,10 @@ LOGGER: Logger = getLogger(__package__) NAME = "Apple WeatherKit" DOMAIN = "weatherkit" -ATTRIBUTION = "Data provided by Apple Weather. https://developer.apple.com/weatherkit/data-source-attribution/" +ATTRIBUTION = ( + "Data provided by Apple Weather. " + "https://developer.apple.com/weatherkit/data-source-attribution/" +) CONF_KEY_ID = "key_id" CONF_SERVICE_ID = "service_id" diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index fc6b0dac1cb..07745680b01 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -5,6 +5,18 @@ from typing import Any, cast from apple_weatherkit import DataSetType from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_EXCEPTIONAL, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, Forecast, SingleCoordinatorWeatherEntity, WeatherEntityFeature, @@ -40,71 +52,71 @@ async def async_setup_entry( condition_code_to_hass = { - "BlowingDust": "windy", - "Clear": "sunny", - "Cloudy": "cloudy", - "Foggy": "fog", - "Haze": "fog", - "MostlyClear": "sunny", - "MostlyCloudy": "cloudy", - "PartlyCloudy": "partlycloudy", - "Smoky": "fog", - "Breezy": "windy", - "Windy": "windy", - "Drizzle": "rainy", - "HeavyRain": "pouring", - "IsolatedThunderstorms": "lightning", - "Rain": "rainy", - "SunShowers": "rainy", - "ScatteredThunderstorms": "lightning", - "StrongStorms": "lightning", - "Thunderstorms": "lightning", - "Frigid": "snowy", - "Hail": "hail", - "Hot": "sunny", - "Flurries": "snowy", - "Sleet": "snowy", - "Snow": "snowy", - "SunFlurries": "snowy", - "WintryMix": "snowy", - "Blizzard": "snowy", - "BlowingSnow": "snowy", - "FreezingDrizzle": "snowy-rainy", - "FreezingRain": "snowy-rainy", - "HeavySnow": "snowy", - "Hurricane": "exceptional", - "TropicalStorm": "exceptional", + "BlowingDust": ATTR_CONDITION_WINDY, + "Clear": ATTR_CONDITION_SUNNY, + "Cloudy": ATTR_CONDITION_CLOUDY, + "Foggy": ATTR_CONDITION_FOG, + "Haze": ATTR_CONDITION_FOG, + "MostlyClear": ATTR_CONDITION_SUNNY, + "MostlyCloudy": ATTR_CONDITION_CLOUDY, + "PartlyCloudy": ATTR_CONDITION_PARTLYCLOUDY, + "Smoky": ATTR_CONDITION_FOG, + "Breezy": ATTR_CONDITION_WINDY, + "Windy": ATTR_CONDITION_WINDY, + "Drizzle": ATTR_CONDITION_RAINY, + "HeavyRain": ATTR_CONDITION_POURING, + "IsolatedThunderstorms": ATTR_CONDITION_LIGHTNING, + "Rain": ATTR_CONDITION_RAINY, + "SunShowers": ATTR_CONDITION_RAINY, + "ScatteredThunderstorms": ATTR_CONDITION_LIGHTNING, + "StrongStorms": ATTR_CONDITION_LIGHTNING, + "Thunderstorms": ATTR_CONDITION_LIGHTNING, + "Frigid": ATTR_CONDITION_SNOWY, + "Hail": ATTR_CONDITION_HAIL, + "Hot": ATTR_CONDITION_SUNNY, + "Flurries": ATTR_CONDITION_SNOWY, + "Sleet": ATTR_CONDITION_SNOWY, + "Snow": ATTR_CONDITION_SNOWY, + "SunFlurries": ATTR_CONDITION_SNOWY, + "WintryMix": ATTR_CONDITION_SNOWY, + "Blizzard": ATTR_CONDITION_SNOWY, + "BlowingSnow": ATTR_CONDITION_SNOWY, + "FreezingDrizzle": ATTR_CONDITION_SNOWY_RAINY, + "FreezingRain": ATTR_CONDITION_SNOWY_RAINY, + "HeavySnow": ATTR_CONDITION_SNOWY, + "Hurricane": ATTR_CONDITION_EXCEPTIONAL, + "TropicalStorm": ATTR_CONDITION_EXCEPTIONAL, } -def _map_daily_forecast(forecast) -> Forecast: +def _map_daily_forecast(forecast: dict[str, Any]) -> Forecast: return { - "datetime": forecast.get("forecastStart"), - "condition": condition_code_to_hass[forecast.get("conditionCode")], - "native_temperature": forecast.get("temperatureMax"), - "native_templow": forecast.get("temperatureMin"), - "native_precipitation": forecast.get("precipitationAmount"), - "precipitation_probability": forecast.get("precipitationChance") * 100, - "uv_index": forecast.get("maxUvIndex"), + "datetime": forecast["forecastStart"], + "condition": condition_code_to_hass[forecast["conditionCode"]], + "native_temperature": forecast["temperatureMax"], + "native_templow": forecast["temperatureMin"], + "native_precipitation": forecast["precipitationAmount"], + "precipitation_probability": forecast["precipitationChance"] * 100, + "uv_index": forecast["maxUvIndex"], } -def _map_hourly_forecast(forecast) -> Forecast: +def _map_hourly_forecast(forecast: dict[str, Any]) -> Forecast: return { - "datetime": forecast.get("forecastStart"), - "condition": condition_code_to_hass[forecast.get("conditionCode")], - "native_temperature": forecast.get("temperature"), - "native_apparent_temperature": forecast.get("temperatureApparent"), + "datetime": forecast["forecastStart"], + "condition": condition_code_to_hass[forecast["conditionCode"]], + "native_temperature": forecast["temperature"], + "native_apparent_temperature": forecast["temperatureApparent"], "native_dew_point": forecast.get("temperatureDewPoint"), - "native_pressure": forecast.get("pressure"), + "native_pressure": forecast["pressure"], "native_wind_gust_speed": forecast.get("windGust"), - "native_wind_speed": forecast.get("windSpeed"), + "native_wind_speed": forecast["windSpeed"], "wind_bearing": forecast.get("windDirection"), - "humidity": forecast.get("humidity") * 100, + "humidity": forecast["humidity"] * 100, "native_precipitation": forecast.get("precipitationAmount"), - "precipitation_probability": forecast.get("precipitationChance") * 100, - "cloud_coverage": forecast.get("cloudCover") * 100, - "uv_index": forecast.get("uvIndex"), + "precipitation_probability": forecast["precipitationChance"] * 100, + "cloud_coverage": forecast["cloudCover"] * 100, + "uv_index": forecast["uvIndex"], } @@ -142,10 +154,11 @@ class WeatherKitWeather( @property def supported_features(self) -> WeatherEntityFeature: """Determine supported features based on available data sets reported by WeatherKit.""" - if not self.coordinator.supported_data_sets: - return WeatherEntityFeature(0) - features = WeatherEntityFeature(0) + + if not self.coordinator.supported_data_sets: + return features + if DataSetType.DAILY_FORECAST in self.coordinator.supported_data_sets: features |= WeatherEntityFeature.FORECAST_DAILY if DataSetType.HOURLY_FORECAST in self.coordinator.supported_data_sets: diff --git a/tests/components/weatherkit/test_config_flow.py b/tests/components/weatherkit/test_config_flow.py index 4faaac15db6..3b6cf76a3d5 100644 --- a/tests/components/weatherkit/test_config_flow.py +++ b/tests/components/weatherkit/test_config_flow.py @@ -40,26 +40,6 @@ EXAMPLE_USER_INPUT = { } -async def _test_exception_generates_error( - hass: HomeAssistant, exception: Exception, error: str -) -> None: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - EXAMPLE_USER_INPUT, - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": error} - - async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form and create an entry.""" result = await hass.config_entries.flow.async_init( @@ -69,8 +49,8 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.weatherkit.config_flow.WeatherKitFlowHandler._test_config", - return_value=None, + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[DataSetType.CURRENT_WEATHER], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -100,7 +80,21 @@ async def test_error_handling( hass: HomeAssistant, exception: Exception, expected_error: str ) -> None: """Test that we handle various exceptions and generate appropriate errors.""" - await _test_exception_generates_error(hass, exception, expected_error) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": expected_error} async def test_form_unsupported_location(hass: HomeAssistant) -> None: diff --git a/tests/components/weatherkit/test_setup.py b/tests/components/weatherkit/test_setup.py index 5f94d4100d5..d71ecbda1b0 100644 --- a/tests/components/weatherkit/test_setup.py +++ b/tests/components/weatherkit/test_setup.py @@ -5,13 +5,10 @@ from apple_weatherkit.client import ( WeatherKitApiClientAuthenticationError, WeatherKitApiClientError, ) -import pytest from homeassistant import config_entries -from homeassistant.components.weatherkit import async_setup_entry from homeassistant.components.weatherkit.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from . import EXAMPLE_CONFIG_DATA @@ -50,7 +47,7 @@ async def test_client_error_handling(hass: HomeAssistant) -> None: data=EXAMPLE_CONFIG_DATA, ) - with pytest.raises(ConfigEntryNotReady), patch( + with patch( "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", side_effect=WeatherKitApiClientError, ), patch( @@ -58,6 +55,7 @@ async def test_client_error_handling(hass: HomeAssistant) -> None: side_effect=WeatherKitApiClientError, ): entry.add_to_hass(hass) - config_entries.current_entry.set(entry) - await async_setup_entry(hass, entry) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + assert entry.state == config_entries.ConfigEntryState.SETUP_RETRY From 1c10091d620218044e858f083c4400645d2ad74b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 09:34:45 +0200 Subject: [PATCH 495/984] Bump docker/login-action from 2.2.0 to 3.0.0 (#100264) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0694b1b75e0..0b0983a001f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -268,7 +268,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -339,13 +339,13 @@ jobs: cosign-release: "v2.0.2" - name: Login to DockerHub - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From e5de7eacadeaee2b4ad631aaa9fbbc4305ea5035 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 13 Sep 2023 11:21:55 +0300 Subject: [PATCH 496/984] Bump sensirion-ble to 0.1.1 (#100271) Bump to sensirion-ble==0.1.1 Fixes akx/sensirion-ble#6 Refs https://github.com/home-assistant/core/issues/93678#issuecomment-1694522112 --- homeassistant/components/sensirion_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensirion_ble/manifest.json b/homeassistant/components/sensirion_ble/manifest.json index 38f66a88e8e..01ccc873f56 100644 --- a/homeassistant/components/sensirion_ble/manifest.json +++ b/homeassistant/components/sensirion_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensirion_ble", "iot_class": "local_push", - "requirements": ["sensirion-ble==0.1.0"] + "requirements": ["sensirion-ble==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 42eaf990045..9f957e886d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2379,7 +2379,7 @@ sense-energy==0.12.1 sense_energy==0.12.1 # homeassistant.components.sensirion_ble -sensirion-ble==0.1.0 +sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17a9d08d37c..eaeaa7496a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ sense-energy==0.12.1 sense_energy==0.12.1 # homeassistant.components.sensirion_ble -sensirion-ble==0.1.0 +sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 From aedd06b9a9257addd6108f98a80598c75f703673 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 11:14:01 +0200 Subject: [PATCH 497/984] Tweak entity/source WS command handler (#100272) --- homeassistant/components/websocket_api/commands.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index e140fef861e..bd7d3b530cd 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -599,10 +599,10 @@ def _serialize_entity_sources( entity_infos: dict[str, entity.EntityInfo] ) -> dict[str, Any]: """Prepare a websocket response from a dict of entity sources.""" - result = {} - for entity_id, entity_info in entity_infos.items(): - result[entity_id] = {"domain": entity_info["domain"]} - return result + return { + entity_id: {"domain": entity_info["domain"]} + for entity_id, entity_info in entity_infos.items() + } @callback From 29d8be510e38a1b8e87e2e41dde155dd39f12569 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Sep 2023 12:40:26 +0200 Subject: [PATCH 498/984] Test speedtest.net config entry lifecycle (#100280) --- tests/components/speedtestdotnet/test_init.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index c6804f48401..5083f56a8e2 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -5,7 +5,11 @@ from unittest.mock import MagicMock import speedtest -from homeassistant.components.speedtestdotnet.const import DOMAIN +from homeassistant.components.speedtestdotnet.const import ( + CONF_SERVER_ID, + CONF_SERVER_NAME, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -27,16 +31,24 @@ async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test removing SpeedTestDotNet.""" +async def test_entry_lifecycle(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test the SpeedTestDotNet entry lifecycle.""" entry = MockConfigEntry( domain=DOMAIN, + data={}, + options={ + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SERVER_ID: "1", + }, ) 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 hass.data[DOMAIN] + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() From 4f8e28a78150a616080b739107129bab95859b2c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 12:41:28 +0200 Subject: [PATCH 499/984] Future proof assist_pipeline.Pipeline (#100277) --- .../components/assist_pipeline/pipeline.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 520daa9f5c2..f4d060ed7b8 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -298,6 +298,26 @@ class Pipeline: id: str = field(default_factory=ulid_util.ulid) + @classmethod + def from_json(cls, data: dict[str, Any]) -> Pipeline: + """Create an instance from a JSON serialization. + + This function was added in HA Core 2023.10, previous versions will raise + if there are unexpected items in the serialized data. + """ + return cls( + conversation_engine=data["conversation_engine"], + conversation_language=data["conversation_language"], + id=data["id"], + language=data["language"], + name=data["name"], + stt_engine=data["stt_engine"], + stt_language=data["stt_language"], + tts_engine=data["tts_engine"], + tts_language=data["tts_language"], + tts_voice=data["tts_voice"], + ) + def to_json(self) -> dict[str, Any]: """Return a JSON serializable representation for storage.""" return { @@ -1205,7 +1225,7 @@ class PipelineStorageCollection( def _deserialize_item(self, data: dict) -> Pipeline: """Create an item from its serialized representation.""" - return Pipeline(**data) + return Pipeline.from_json(data) def _serialize_item(self, item_id: str, item: Pipeline) -> dict: """Return the serialized representation of an item for storing.""" From 684b2d45370813834c5dff613740d3a0a124f3ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 12:42:06 +0200 Subject: [PATCH 500/984] Improve type hint in entity_registry (#100278) --- homeassistant/helpers/entity_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index ff2ca255279..939c8986e71 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -430,7 +430,7 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): return data -class EntityRegistryItems(UserDict[str, "RegistryEntry"]): +class EntityRegistryItems(UserDict[str, RegistryEntry]): """Container for entity registry items, maps entity_id -> entry. Maintains two additional indexes: From 7fe78fe9e43c7ce325ccb867529d53d76ed0ca6c Mon Sep 17 00:00:00 2001 From: Olen Date: Wed, 13 Sep 2023 13:09:57 +0200 Subject: [PATCH 501/984] Add diagnostics to Twinkly (#100146) --- CODEOWNERS | 4 +- .../components/twinkly/diagnostics.py | 40 ++++++++++++++ .../components/twinkly/manifest.json | 2 +- tests/components/twinkly/__init__.py | 7 +-- tests/components/twinkly/conftest.py | 54 +++++++++++++++++++ .../twinkly/snapshots/test_diagnostics.ambr | 43 +++++++++++++++ tests/components/twinkly/test_config_flow.py | 12 ++--- tests/components/twinkly/test_diagnostics.py | 28 ++++++++++ tests/components/twinkly/test_light.py | 10 ++-- 9 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/twinkly/diagnostics.py create mode 100644 tests/components/twinkly/conftest.py create mode 100644 tests/components/twinkly/snapshots/test_diagnostics.ambr create mode 100644 tests/components/twinkly/test_diagnostics.py diff --git a/CODEOWNERS b/CODEOWNERS index bba1c2debbf..3aefaabb50b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1324,8 +1324,8 @@ build.json @home-assistant/supervisor /tests/components/tuya/ @Tuya @zlinoliver @frenck /homeassistant/components/twentemilieu/ @frenck /tests/components/twentemilieu/ @frenck -/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 -/tests/components/twinkly/ @dr1rrb @Robbie1221 +/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen +/tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen /homeassistant/components/twitch/ @joostlek /tests/components/twitch/ @joostlek /homeassistant/components/ukraine_alarm/ @PaulAnnekov diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py new file mode 100644 index 00000000000..06afba5782b --- /dev/null +++ b/homeassistant/components/twinkly/diagnostics.py @@ -0,0 +1,40 @@ +"""Diagnostics support for Twinkly.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import DATA_DEVICE_INFO, DOMAIN + +TO_REDACT = [CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Twinkly config entry.""" + attributes = None + state = None + entity_registry = er.async_get(hass) + + entity_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, str(entry.unique_id) + ) + if entity_id: + state = hass.states.get(entity_id) + if state: + attributes = state.attributes + return async_redact_data( + { + "entry": entry.as_dict(), + "device_info": hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO], + "attributes": attributes, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/twinkly/manifest.json b/homeassistant/components/twinkly/manifest.json index 59deff915c3..c6ab0bab893 100644 --- a/homeassistant/components/twinkly/manifest.json +++ b/homeassistant/components/twinkly/manifest.json @@ -1,7 +1,7 @@ { "domain": "twinkly", "name": "Twinkly", - "codeowners": ["@dr1rrb", "@Robbie1221"], + "codeowners": ["@dr1rrb", "@Robbie1221", "@Olen"], "config_flow": true, "dhcp": [ { diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index 31d1eff2a61..0780bc0126f 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -1,6 +1,5 @@ """Constants and mock for the twkinly component tests.""" -from uuid import uuid4 from aiohttp.client_exceptions import ClientConnectionError @@ -8,6 +7,7 @@ from homeassistant.components.twinkly.const import DEV_NAME TEST_HOST = "test.twinkly.com" TEST_ID = "twinkly_test_device_id" +TEST_UID = "4c8fccf5-e08a-4173-92d5-49bf479252a2" TEST_NAME = "twinkly_test_device_name" TEST_NAME_ORIGINAL = "twinkly_test_original_device_name" # the original (deprecated) name stored in the conf TEST_MODEL = "twinkly_test_device_model" @@ -28,11 +28,12 @@ class ClientMock: self.mode = None self.version = "2.8.10" - self.id = str(uuid4()) + self.id = TEST_UID self.device_info = { "uuid": self.id, - "device_name": self.id, # we make sure that entity id is different for each test + "device_name": TEST_NAME, "product_code": TEST_MODEL, + "sw_version": self.version, } @property diff --git a/tests/components/twinkly/conftest.py b/tests/components/twinkly/conftest.py new file mode 100644 index 00000000000..5a689c31baa --- /dev/null +++ b/tests/components/twinkly/conftest.py @@ -0,0 +1,54 @@ +"""Configure tests for the Twinkly integration.""" +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import TEST_MODEL, TEST_NAME, TEST_UID, ClientMock + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[], Awaitable[ClientMock]] + +DOMAIN = "twinkly" +TITLE = "Twinkly" + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Create Twinkly entry in Home Assistant.""" + client = ClientMock() + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=TEST_UID, + entry_id=TEST_UID, + data={ + "host": client.host, + "id": client.id, + "name": TEST_NAME, + "model": TEST_MODEL, + "device_name": TEST_NAME, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Callable[[], Coroutine[Any, Any, ClientMock]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + async def func() -> ClientMock: + mock = ClientMock() + with patch("homeassistant.components.twinkly.Twinkly", return_value=mock): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + return mock + + return func diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c5788444845 --- /dev/null +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'attributes': dict({ + 'brightness': 26, + 'color_mode': 'brightness', + 'effect_list': list([ + ]), + 'friendly_name': 'twinkly_test_device_name', + 'icon': 'mdi:string-lights', + 'supported_color_modes': list([ + 'brightness', + ]), + 'supported_features': 4, + }), + 'device_info': dict({ + 'device_name': 'twinkly_test_device_name', + 'product_code': 'twinkly_test_device_model', + 'sw_version': '2.8.10', + 'uuid': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + }), + 'entry': dict({ + 'data': dict({ + 'device_name': 'twinkly_test_device_name', + 'host': '**REDACTED**', + 'id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'model': 'twinkly_test_device_model', + 'name': 'twinkly_test_device_name', + }), + 'disabled_by': None, + 'domain': 'twinkly', + 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Twinkly', + 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 1219130c197..2d335c69923 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.components.twinkly.const import ( from homeassistant.const import CONF_MODEL from homeassistant.core import HomeAssistant -from . import TEST_MODEL, ClientMock +from . import TEST_MODEL, TEST_NAME, ClientMock from tests.common import MockConfigEntry @@ -60,11 +60,11 @@ async def test_success_flow(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" - assert result["title"] == client.id + assert result["title"] == TEST_NAME assert result["data"] == { CONF_HOST: "dummy", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, } @@ -113,11 +113,11 @@ async def test_dhcp_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["title"] == client.id + assert result["title"] == TEST_NAME assert result["data"] == { CONF_HOST: "1.2.3.4", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, } @@ -131,7 +131,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: data={ CONF_HOST: "1.2.3.4", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, }, unique_id=client.id, diff --git a/tests/components/twinkly/test_diagnostics.py b/tests/components/twinkly/test_diagnostics.py new file mode 100644 index 00000000000..ab07cabef4a --- /dev/null +++ b/tests/components/twinkly/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics of the twinkly component.""" +from collections.abc import Awaitable, Callable + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import ClientMock + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +ComponentSetup = Callable[[], Awaitable[ClientMock]] + +DOMAIN = "twinkly" + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index f66c82dc2ed..bcb40f22d08 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry -from . import TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock +from . import TEST_MODEL, TEST_NAME, TEST_NAME_ORIGINAL, ClientMock from tests.common import MockConfigEntry @@ -28,16 +28,16 @@ async def test_initial_state(hass: HomeAssistant) -> None: state = hass.states.get(entity.entity_id) # Basic state properties - assert state.name == entity.unique_id + assert state.name == TEST_NAME assert state.state == "on" assert state.attributes[ATTR_BRIGHTNESS] == 26 - assert state.attributes["friendly_name"] == entity.unique_id + assert state.attributes["friendly_name"] == TEST_NAME assert state.attributes["icon"] == "mdi:string-lights" - assert entity.original_name == entity.unique_id + assert entity.original_name == TEST_NAME assert entity.original_icon == "mdi:string-lights" - assert device.name == entity.unique_id + assert device.name == TEST_NAME assert device.model == TEST_MODEL assert device.manufacturer == "LEDWORKS" From 705ee3032b68f51459f10736d17360810a3479fa Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 13:13:27 +0200 Subject: [PATCH 502/984] Use shorthanded attrs for yamaha_musiccast select (#100273) --- .../components/yamaha_musiccast/select.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index a8ca6162c91..8ef9df1ba2f 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -24,37 +24,39 @@ async def async_setup_entry( for capability in coordinator.data.capabilities: if isinstance(capability, OptionSetter): - select_entities.append(SelectableCapapility(coordinator, capability)) + select_entities.append(SelectableCapability(coordinator, capability)) for zone, data in coordinator.data.zones.items(): for capability in data.capabilities: if isinstance(capability, OptionSetter): select_entities.append( - SelectableCapapility(coordinator, capability, zone) + SelectableCapability(coordinator, capability, zone) ) async_add_entities(select_entities) -class SelectableCapapility(MusicCastCapabilityEntity, SelectEntity): +class SelectableCapability(MusicCastCapabilityEntity, SelectEntity): """Representation of a MusicCast Select entity.""" capability: OptionSetter + def __init__( + self, + coordinator: MusicCastDataUpdateCoordinator, + capability: OptionSetter, + zone_id: str | None = None, + ) -> None: + """Initialize the MusicCast Select entity.""" + MusicCastCapabilityEntity.__init__(self, coordinator, capability, zone_id) + self._attr_options = list(capability.options.values()) + self._attr_translation_key = TRANSLATION_KEY_MAPPING.get(capability.id) + async def async_select_option(self, option: str) -> None: """Select the given option.""" value = {val: key for key, val in self.capability.options.items()}[option] await self.capability.set(value) - - @property - def translation_key(self) -> str | None: - """Return the translation key to translate the entity's states.""" - return TRANSLATION_KEY_MAPPING.get(self.capability.id) - - @property - def options(self) -> list[str]: - """Return the list possible options.""" - return list(self.capability.options.values()) + self._attr_translation_key = TRANSLATION_KEY_MAPPING.get(self.capability.id) @property def current_option(self) -> str | None: From 1f1411b6a545e3204d766faea222a130dea8b800 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 13 Sep 2023 13:22:37 +0200 Subject: [PATCH 503/984] Move sms coordinators to their own file (#100276) --- homeassistant/components/sms/__init__.py | 52 +------------------ homeassistant/components/sms/coordinator.py | 56 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/sms/coordinator.py diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 824a95e36b1..a606b83896f 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -1,9 +1,6 @@ """The sms component.""" -import asyncio -from datetime import timedelta import logging -import gammu import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -12,12 +9,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_BAUD_SPEED, DEFAULT_BAUD_SPEED, - DEFAULT_SCAN_INTERVAL, DOMAIN, GATEWAY, HASS_CONFIG, @@ -25,6 +20,7 @@ from .const import ( SIGNAL_COORDINATOR, SMS_GATEWAY, ) +from .coordinator import NetworkCoordinator, SignalCoordinator from .gateway import create_sms_gateway _LOGGER = logging.getLogger(__name__) @@ -45,8 +41,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Configure Gammu state machine.""" @@ -107,47 +101,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await gateway.terminate_async() return unload_ok - - -class SignalCoordinator(DataUpdateCoordinator): - """Signal strength coordinator.""" - - def __init__(self, hass, gateway): - """Initialize signal strength coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Device signal state", - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ) - self._gateway = gateway - - async def _async_update_data(self): - """Fetch device signal quality.""" - try: - async with asyncio.timeout(10): - return await self._gateway.get_signal_quality_async() - except gammu.GSMError as exc: - raise UpdateFailed(f"Error communicating with device: {exc}") from exc - - -class NetworkCoordinator(DataUpdateCoordinator): - """Network info coordinator.""" - - def __init__(self, hass, gateway): - """Initialize network info coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Device network state", - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ) - self._gateway = gateway - - async def _async_update_data(self): - """Fetch device network info.""" - try: - async with asyncio.timeout(10): - return await self._gateway.get_network_info_async() - except gammu.GSMError as exc: - raise UpdateFailed(f"Error communicating with device: {exc}") from exc diff --git a/homeassistant/components/sms/coordinator.py b/homeassistant/components/sms/coordinator.py new file mode 100644 index 00000000000..fd212fce4f2 --- /dev/null +++ b/homeassistant/components/sms/coordinator.py @@ -0,0 +1,56 @@ +"""DataUpdateCoordinators for the sms integration.""" +import asyncio +from datetime import timedelta +import logging + +import gammu + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class SignalCoordinator(DataUpdateCoordinator): + """Signal strength coordinator.""" + + def __init__(self, hass, gateway): + """Initialize signal strength coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Device signal state", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._gateway = gateway + + async def _async_update_data(self): + """Fetch device signal quality.""" + try: + async with asyncio.timeout(10): + return await self._gateway.get_signal_quality_async() + except gammu.GSMError as exc: + raise UpdateFailed(f"Error communicating with device: {exc}") from exc + + +class NetworkCoordinator(DataUpdateCoordinator): + """Network info coordinator.""" + + def __init__(self, hass, gateway): + """Initialize network info coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Device network state", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._gateway = gateway + + async def _async_update_data(self): + """Fetch device network info.""" + try: + async with asyncio.timeout(10): + return await self._gateway.get_network_info_async() + except gammu.GSMError as exc: + raise UpdateFailed(f"Error communicating with device: {exc}") from exc From 958b9237836ed96e066b4fc84a6f8287aeda4c22 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 13 Sep 2023 13:29:20 +0200 Subject: [PATCH 504/984] Limit waze_travel_time to 1 call every 0.5s (#100191) --- homeassistant/components/waze_travel_time/sensor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 2b3010a39cb..bf3544de8a9 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -1,6 +1,7 @@ """Support for Waze travel time sensor.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any @@ -48,6 +49,10 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) +PARALLEL_UPDATES = 1 + +MS_BETWEEN_API_CALLS = 0.5 + async def async_setup_entry( hass: HomeAssistant, @@ -144,6 +149,7 @@ class WazeTravelTime(SensorEntity): self._waze_data.origin = find_coordinates(self.hass, self._origin) self._waze_data.destination = find_coordinates(self.hass, self._destination) await self._waze_data.async_update() + await asyncio.sleep(MS_BETWEEN_API_CALLS) class WazeTravelTimeData: From 9f3b1a8d44cd3ea2a5f928e199ae842e6b25b4fd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 13:32:00 +0200 Subject: [PATCH 505/984] Use hass.loop.create_future() in zha (#100056) * Use hass.loop.create_future() in zha * Remove not needed method --- homeassistant/components/zha/entity.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 5722d91116a..da34b829907 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -59,7 +59,6 @@ class BaseZhaEntity(LogMixin, entity.Entity): self._extra_state_attributes: dict[str, Any] = {} self._zha_device = zha_device self._unsubs: list[Callable[[], None]] = [] - self.remove_future: asyncio.Future[Any] = asyncio.Future() @property def unique_id(self) -> str: @@ -143,6 +142,8 @@ class BaseZhaEntity(LogMixin, entity.Entity): class ZhaEntity(BaseZhaEntity, RestoreEntity): """A base class for non group ZHA entities.""" + remove_future: asyncio.Future[Any] + def __init_subclass__(cls, id_suffix: str | None = None, **kwargs: Any) -> None: """Initialize subclass. @@ -188,7 +189,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" - self.remove_future = asyncio.Future() + self.remove_future = self.hass.loop.create_future() self.async_accept_signal( None, f"{SIGNAL_REMOVE}_{self.zha_device.ieee}", From d638efdcfcf240eb2903cf0032681739ebc1ffe8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 13:50:00 +0200 Subject: [PATCH 506/984] Use shorthanded attrs for vera sensor (#100269) Co-authored-by: Franck Nijhof --- homeassistant/components/vera/sensor.py | 45 +++++++++---------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 942ebc77acd..58e350bd034 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -55,35 +55,22 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): self.last_changed_time = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this entity.""" if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - return SensorDeviceClass.TEMPERATURE + self._attr_device_class = SensorDeviceClass.TEMPERATURE + elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: + self._attr_device_class = SensorDeviceClass.ILLUMINANCE + elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: + self._attr_device_class = SensorDeviceClass.HUMIDITY + elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: + self._attr_device_class = SensorDeviceClass.POWER if self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - return SensorDeviceClass.ILLUMINANCE - if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - return SensorDeviceClass.HUMIDITY - if self.vera_device.category == veraApi.CATEGORY_POWER_METER: - return SensorDeviceClass.POWER - return None - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - - if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - return self._temperature_units - if self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - return LIGHT_LUX - if self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: - return "level" - if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - return PERCENTAGE - if self.vera_device.category == veraApi.CATEGORY_POWER_METER: - return UnitOfPower.WATT - return None + self._attr_native_unit_of_measurement = LIGHT_LUX + elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: + self._attr_native_unit_of_measurement = "level" + elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: + self._attr_native_unit_of_measurement = PERCENTAGE + elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: + self._attr_native_unit_of_measurement = UnitOfPower.WATT def update(self) -> None: """Update the state.""" @@ -94,9 +81,9 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): vera_temp_units = self.vera_device.vera_controller.temperature_units if vera_temp_units == "F": - self._temperature_units = UnitOfTemperature.FAHRENHEIT + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT else: - self._temperature_units = UnitOfTemperature.CELSIUS + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: self._attr_native_value = self.vera_device.light From 38e013a90e3c0c6a3a739ff60efb291b969c8827 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Sep 2023 14:15:40 +0200 Subject: [PATCH 507/984] Remove NZBGet configurable scan interval (#98869) --- homeassistant/components/nzbget/__init__.py | 12 +---- .../components/nzbget/config_flow.py | 45 +------------------ homeassistant/components/nzbget/const.py | 1 - .../components/nzbget/coordinator.py | 9 +--- homeassistant/components/nzbget/strings.json | 9 ---- tests/components/nzbget/test_config_flow.py | 32 +------------ 6 files changed, 5 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index c3b6aab619b..9d6fafd30c7 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -2,7 +2,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -12,7 +12,6 @@ from .const import ( ATTR_SPEED, DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, - DEFAULT_SCAN_INTERVAL, DEFAULT_SPEED_LIMIT, DOMAIN, SERVICE_PAUSE, @@ -34,18 +33,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NZBGet from a config entry.""" hass.data.setdefault(DOMAIN, {}) - if not entry.options: - options = { - CONF_SCAN_INTERVAL: entry.data.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - } - hass.config_entries.async_update_entry(entry, options=options) - coordinator = NZBGetDataUpdateCoordinator( hass, config=entry.data, - options=entry.options, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index 732ef879762..782ec791eeb 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -6,28 +6,19 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import ( - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SSL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import DEFAULT_NAME, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN from .coordinator import NZBGetAPI, NZBGetAPIException _LOGGER = logging.getLogger(__name__) @@ -55,12 +46,6 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> NZBGetOptionsFlowHandler: - """Get the options flow for this handler.""" - return NZBGetOptionsFlowHandler(config_entry) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -106,29 +91,3 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema(data_schema), errors=errors or {}, ) - - -class NZBGetOptionsFlowHandler(OptionsFlow): - """Handle NZBGet client options.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Manage NZBGet options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - options = { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): int, - } - - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/nzbget/const.py b/homeassistant/components/nzbget/const.py index 928487738eb..7838d64c6d7 100644 --- a/homeassistant/components/nzbget/const.py +++ b/homeassistant/components/nzbget/const.py @@ -11,7 +11,6 @@ DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" # Defaults DEFAULT_NAME = "NZBGet" DEFAULT_PORT = 6789 -DEFAULT_SCAN_INTERVAL = 5 # time in seconds DEFAULT_SPEED_LIMIT = 1000 # 1 Megabyte/Sec DEFAULT_SSL = False DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index 7326fa50dd5..dcefe25eae9 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -11,7 +11,6 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -32,7 +31,6 @@ class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): hass: HomeAssistant, *, config: Mapping[str, Any], - options: Mapping[str, Any], ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( @@ -47,13 +45,8 @@ class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): self._completed_downloads_init = False self._completed_downloads = set[tuple]() - update_interval = timedelta(seconds=options[CONF_SCAN_INTERVAL]) - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=update_interval, + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=5) ) def _check_completed_downloads(self, history): diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index a1faa63bb39..4da9a0b505e 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -23,15 +23,6 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Update frequency (seconds)" - } - } - } - }, "entity": { "sensor": { "article_cache": { diff --git a/tests/components/nzbget/test_config_flow.py b/tests/components/nzbget/test_config_flow.py index c078a6523bc..e26be8b9880 100644 --- a/tests/components/nzbget/test_config_flow.py +++ b/tests/components/nzbget/test_config_flow.py @@ -5,7 +5,7 @@ from pynzbgetapi import NZBGetAPIException from homeassistant.components.nzbget.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_VERIFY_SSL +from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -122,33 +122,3 @@ async def test_user_form_single_instance_allowed(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" - - -async def test_options_flow(hass: HomeAssistant, nzbget_api) -> None: - """Test updating options.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=ENTRY_CONFIG, - options={CONF_SCAN_INTERVAL: 5}, - ) - entry.add_to_hass(hass) - - with patch("homeassistant.components.nzbget.PLATFORMS", []): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.options[CONF_SCAN_INTERVAL] == 5 - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - - with _patch_async_setup_entry(): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 15}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_SCAN_INTERVAL] == 15 From afa015226134f57e1f7b610d9077cba2b5e6de42 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 13 Sep 2023 14:40:01 +0200 Subject: [PATCH 508/984] Update syrupy to 4.5.0 (#100283) --- requirements_test.txt | 2 +- tests/syrupy.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index ba636c56649..8da4e92c81d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -31,7 +31,7 @@ pytest-xdist==3.3.1 pytest==7.3.1 requests_mock==1.11.0 respx==0.20.2 -syrupy==4.2.1 +syrupy==4.5.0 tqdm==4.66.1 types-aiofiles==22.1.0 types-atomicwrites==1.4.5.1 diff --git a/tests/syrupy.py b/tests/syrupy.py index 9433eb1649c..c7d114a4812 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -85,6 +85,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): *, depth: int = 0, exclude: PropertyFilter | None = None, + include: PropertyFilter | None = None, matcher: PropertyMatcher | None = None, path: PropertyPath = (), visited: set[Any] | None = None, @@ -125,6 +126,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data, depth=depth, exclude=exclude, + include=include, matcher=matcher, path=path, visited=visited, From 65c9e5ee13c35fd1ed8179ae140945e06d2c76f0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 13 Sep 2023 14:40:27 +0200 Subject: [PATCH 509/984] Update mutagen to 1.47.0 (#100284) --- homeassistant/components/tts/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/tts/manifest.json b/homeassistant/components/tts/manifest.json index 249e427c591..f1120ed2750 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -8,5 +8,5 @@ "integration_type": "entity", "loggers": ["mutagen"], "quality_scale": "internal", - "requirements": ["mutagen==1.46.0"] + "requirements": ["mutagen==1.47.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd6a130a2fc..ed972c39c2c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 -mutagen==1.46.0 +mutagen==1.47.0 orjson==3.9.7 packaging>=23.1 paho-mqtt==1.6.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9f957e886d7..c27c11b6a3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1241,7 +1241,7 @@ motioneye-client==0.3.14 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.46.0 +mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eaeaa7496a8..aceab8ce5a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -961,7 +961,7 @@ motioneye-client==0.3.14 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.46.0 +mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 From d44db6ee6887617b5f34a09bd2735fc23c6997e0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 15:10:35 +0200 Subject: [PATCH 510/984] Use shorthand attrs for xbox base_sensor (#100290) --- homeassistant/components/xbox/base_sensor.py | 30 ++++++-------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/base_sensor.py index ffbbee8637d..9aecb100df0 100644 --- a/homeassistant/components/xbox/base_sensor.py +++ b/homeassistant/components/xbox/base_sensor.py @@ -20,11 +20,15 @@ class XboxBaseSensorEntity(CoordinatorEntity[XboxUpdateCoordinator]): super().__init__(coordinator) self.xuid = xuid self.attribute = attribute - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self.xuid}_{self.attribute}" + self._attr_unique_id = f"{xuid}_{attribute}" + self._attr_entity_registry_enabled_default = attribute == "online" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, "xbox_live")}, + manufacturer="Microsoft", + model="Xbox Live", + name="Xbox Live", + ) @property def data(self) -> PresenceData | None: @@ -61,19 +65,3 @@ class XboxBaseSensorEntity(CoordinatorEntity[XboxUpdateCoordinator]): query = dict(url.query) query.pop("mode", None) return str(url.with_query(query)) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.attribute == "online" - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, "xbox_live")}, - manufacturer="Microsoft", - model="Xbox Live", - name="Xbox Live", - ) From 80aa19263b28996762ec1e36abb46aac9ff01d80 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 13 Sep 2023 15:32:03 +0200 Subject: [PATCH 511/984] Netgear catch no info error (#100212) --- .../components/netgear/config_flow.py | 6 +- homeassistant/components/netgear/strings.json | 3 +- tests/components/netgear/test_config_flow.py | 64 ++++++++----------- 3 files changed, 32 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index da260a2559e..7b74880d011 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -190,8 +190,6 @@ class NetgearFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) except CannotLoginException: errors["base"] = "config" - - if errors: return await self._show_setup_form(user_input, errors) config_data = { @@ -204,6 +202,10 @@ class NetgearFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check if already configured info = await self.hass.async_add_executor_job(api.get_info) + if info is None: + errors["base"] = "info" + return await self._show_setup_form(user_input, errors) + await self.async_set_unique_id(info["SerialNumber"], raise_on_progress=False) self._abort_if_unique_id_configured(updates=config_data) diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index f2af3dd7804..a903535d5a8 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -11,7 +11,8 @@ } }, "error": { - "config": "Connection or login error: please check your configuration" + "config": "Connection or login error: please check your configuration", + "info": "Failed to get info from router" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index 248ad3a69ea..37787024fb6 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -76,41 +76,6 @@ def mock_controller_service(): yield service_mock -@pytest.fixture(name="service_5555") -def mock_controller_service_5555(): - """Mock a successful service.""" - with patch( - "homeassistant.components.netgear.async_setup_entry", return_value=True - ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.get_info = Mock(return_value=ROUTER_INFOS) - service_mock.return_value.port = 5555 - service_mock.return_value.ssl = True - yield service_mock - - -@pytest.fixture(name="service_incomplete") -def mock_controller_service_incomplete(): - """Mock a successful service.""" - router_infos = ROUTER_INFOS.copy() - router_infos.pop("DeviceName") - with patch( - "homeassistant.components.netgear.async_setup_entry", return_value=True - ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.get_info = Mock(return_value=router_infos) - service_mock.return_value.port = 80 - service_mock.return_value.ssl = False - yield service_mock - - -@pytest.fixture(name="service_failed") -def mock_controller_service_failed(): - """Mock a failed service.""" - with patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.login_try_port = Mock(return_value=None) - service_mock.return_value.get_info = Mock(return_value=None) - yield service_mock - - async def test_user(hass: HomeAssistant, service) -> None: """Test user step.""" result = await hass.config_entries.flow.async_init( @@ -138,7 +103,7 @@ async def test_user(hass: HomeAssistant, service) -> None: assert result["data"][CONF_PASSWORD] == PASSWORD -async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: +async def test_user_connect_error(hass: HomeAssistant, service) -> None: """Test user step with connection failure.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -146,7 +111,23 @@ async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + service.return_value.get_info = Mock(return_value=None) + # Have to provide all config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "info"} + + service.return_value.login_try_port = Mock(return_value=None) + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -160,7 +141,7 @@ async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: assert result["errors"] == {"base": "config"} -async def test_user_incomplete_info(hass: HomeAssistant, service_incomplete) -> None: +async def test_user_incomplete_info(hass: HomeAssistant, service) -> None: """Test user step with incomplete device info.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -168,6 +149,10 @@ async def test_user_incomplete_info(hass: HomeAssistant, service_incomplete) -> assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + router_infos = ROUTER_INFOS.copy() + router_infos.pop("DeviceName") + service.return_value.get_info = Mock(return_value=router_infos) + # Have to provide all config result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -313,7 +298,7 @@ async def test_ssdp(hass: HomeAssistant, service) -> None: assert result["data"][CONF_PASSWORD] == PASSWORD -async def test_ssdp_port_5555(hass: HomeAssistant, service_5555) -> None: +async def test_ssdp_port_5555(hass: HomeAssistant, service) -> None: """Test ssdp step with port 5555.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -332,6 +317,9 @@ async def test_ssdp_port_5555(hass: HomeAssistant, service_5555) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + service.return_value.port = 5555 + service.return_value.ssl = True + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: PASSWORD} ) From 8498cdfb3c49adb2e02429d8d62c12365fd45e05 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Sep 2023 15:49:36 +0200 Subject: [PATCH 512/984] Remove profile from Withings config flow (#100202) * Remove profile from Withings config flow * Add config flow migration * Add config flow migration * Remove datamanager profile * Remove datamanager profile * Add manufacturer * Remove migration * Remove migration * Fix feedback --- homeassistant/components/withings/__init__.py | 42 ++-- homeassistant/components/withings/common.py | 16 +- .../components/withings/config_flow.py | 91 +++---- homeassistant/components/withings/const.py | 1 + homeassistant/components/withings/entity.py | 3 +- tests/components/withings/__init__.py | 15 ++ tests/components/withings/conftest.py | 4 +- .../components/withings/test_binary_sensor.py | 3 +- tests/components/withings/test_common.py | 97 -------- tests/components/withings/test_config_flow.py | 132 ++++++---- tests/components/withings/test_init.py | 228 +++++++----------- 11 files changed, 257 insertions(+), 375 deletions(-) delete mode 100644 tests/components/withings/test_common.py diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 682efde8881..841c9da3c70 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at from __future__ import annotations import asyncio -from typing import Any from aiohttp.web import Request, Response import voluptuous as vol @@ -17,12 +16,14 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.webhook import ( + async_generate_id, async_unregister as async_unregister_webhook, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_TOKEN, CONF_WEBHOOK_ID, Platform, ) @@ -39,6 +40,7 @@ from .common import ( get_data_manager_by_webhook_id, json_message_response, ) +from .const import CONF_USE_WEBHOOK, CONFIG DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -103,33 +105,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - config_updates: dict[str, Any] = {} - - # Add a unique id if it's an older config entry. - if entry.unique_id != entry.data["token"]["userid"] or not isinstance( - entry.unique_id, str - ): - config_updates["unique_id"] = str(entry.data["token"]["userid"]) - - # Add the webhook configuration. - if CONF_WEBHOOK_ID not in entry.data: - webhook_id = webhook.async_generate_id() - config_updates["data"] = { - **entry.data, - **{ - const.CONF_USE_WEBHOOK: hass.data[DOMAIN][const.CONFIG][ - const.CONF_USE_WEBHOOK - ], - CONF_WEBHOOK_ID: webhook_id, - }, + if CONF_USE_WEBHOOK not in entry.options: + new_data = entry.data.copy() + new_options = { + CONF_USE_WEBHOOK: new_data.get(CONF_USE_WEBHOOK, False), } + unique_id = str(entry.data[CONF_TOKEN]["userid"]) + if CONF_WEBHOOK_ID not in new_data: + new_data[CONF_WEBHOOK_ID] = async_generate_id() - if config_updates: - hass.config_entries.async_update_entry(entry, **config_updates) + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options, unique_id=unique_id + ) + use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK] + if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: + new_options = entry.options.copy() + new_options |= {CONF_USE_WEBHOOK: use_webhook} + hass.config_entries.async_update_entry(entry, options=new_options) data_manager = await async_get_data_manager(hass, entry) - _LOGGER.debug("Confirming %s is authenticated to withings", data_manager.profile) + _LOGGER.debug("Confirming %s is authenticated to withings", entry.title) await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() webhook.async_register( diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 3d215567f45..98c98f1fa96 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -203,7 +203,6 @@ class DataManager: def __init__( self, hass: HomeAssistant, - profile: str, api: ConfigEntryWithingsApi, user_id: int, webhook_config: WebhookConfig, @@ -212,7 +211,6 @@ class DataManager: self._hass = hass self._api = api self._user_id = user_id - self._profile = profile self._webhook_config = webhook_config self._notify_subscribe_delay = SUBSCRIBE_DELAY self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY @@ -256,11 +254,6 @@ class DataManager: """Get the user_id of the authenticated user.""" return self._user_id - @property - def profile(self) -> str: - """Get the profile.""" - return self._profile - def async_start_polling_webhook_subscriptions(self) -> None: """Start polling webhook subscriptions (if enabled) to reconcile their setup.""" self.async_stop_polling_webhook_subscriptions() @@ -530,12 +523,11 @@ async def async_get_data_manager( config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] if const.DATA_MANAGER not in config_entry_data: - profile: str = config_entry.data[const.PROFILE] - - _LOGGER.debug("Creating withings data manager for profile: %s", profile) + _LOGGER.debug( + "Creating withings data manager for profile: %s", config_entry.title + ) config_entry_data[const.DATA_MANAGER] = DataManager( hass, - profile, ConfigEntryWithingsApi( hass=hass, config_entry=config_entry, @@ -549,7 +541,7 @@ async def async_get_data_manager( url=webhook.async_generate_url( hass, config_entry.data[CONF_WEBHOOK_ID] ), - enabled=config_entry.data[const.CONF_USE_WEBHOOK], + enabled=config_entry.options[const.CONF_USE_WEBHOOK], ), ) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index b0fa1876d92..7bbf869069f 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,26 +5,24 @@ from collections.abc import Mapping import logging from typing import Any -import voluptuous as vol from withings_api.common import AuthScope +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.util import slugify -from . import const +from .const import CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN class WithingsFlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=const.DOMAIN + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): """Handle a config flow.""" - DOMAIN = const.DOMAIN + DOMAIN = DOMAIN - # Temporarily holds authorization data during the profile step. - _current_data: dict[str, None | str | int] = {} - _reauth_profile: str | None = None + reauth_entry: ConfigEntry | None = None @property def logger(self) -> logging.Logger: @@ -45,64 +43,37 @@ class WithingsFlowHandler( ) } - async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: - """Override the create entry so user can select a profile.""" - self._current_data = data - return await self.async_step_profile(data) - - async def async_step_profile(self, data: dict[str, Any]) -> FlowResult: - """Prompt the user to select a user profile.""" - errors = {} - profile = data.get(const.PROFILE) or self._reauth_profile - - if profile: - existing_entries = [ - config_entry - for config_entry in self._async_current_entries() - if slugify(config_entry.data.get(const.PROFILE)) == slugify(profile) - ] - - if self._reauth_profile or not existing_entries: - new_data = {**self._current_data, **data, const.PROFILE: profile} - self._current_data = {} - return await self.async_step_finish(new_data) - - errors["base"] = "already_configured" - - return self.async_show_form( - step_id="profile", - data_schema=vol.Schema({vol.Required(const.PROFILE): str}), - errors=errors, + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] ) - - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: - """Prompt user to re-authenticate.""" - self._reauth_profile = data.get(const.PROFILE) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, data: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Prompt user to re-authenticate.""" - if data is not None: - return await self.async_step_user() + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() - placeholders = {const.PROFILE: self._reauth_profile} + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + user_id = str(data[CONF_TOKEN]["userid"]) + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() - self.context.update({"title_placeholders": placeholders}) + return self.async_create_entry( + title=DEFAULT_TITLE, + data=data, + options={CONF_USE_WEBHOOK: False}, + ) - return self.async_show_form( - step_id="reauth_confirm", - description_placeholders=placeholders, - ) + if self.reauth_entry.unique_id == user_id: + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") - async def async_step_finish(self, data: dict[str, Any]) -> FlowResult: - """Finish the flow.""" - self._current_data = {} - - await self.async_set_unique_id( - str(data["token"]["userid"]), raise_on_progress=False - ) - self._abort_if_unique_id_configured(data) - - return self.async_create_entry(title=data[const.PROFILE], data=data) + return self.async_abort(reason="wrong_account") diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 02d8977c604..926d29abe5c 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,6 +1,7 @@ """Constants used by the Withings component.""" from enum import StrEnum +DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index a1ad8828b81..f17d3ccf03c 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -46,8 +46,7 @@ class BaseWithingsSensor(Entity): ) self._state_data: Any | None = None self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, - name=data_manager.profile, + identifiers={(DOMAIN, str(data_manager.user_id))}, manufacturer="Withings" ) @property diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 94c7511054f..4634a77a8da 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -4,8 +4,10 @@ from typing import Any from urllib.parse import urlparse from homeassistant.components.webhook import async_generate_url +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -48,3 +50,16 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) ) await hass.config_entries.async_setup(config_entry.entry_id) + + +async def enable_webhooks(hass: HomeAssistant) -> None: + """Enable webhooks.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_USE_WEBHOOK: True, + } + }, + ) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index fdd076e2f43..a5e51c68c40 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -96,9 +96,11 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "scope": ",".join(scopes), }, "profile": TITLE, - "use_webhook": True, "webhook_id": WEBHOOK_ID, }, + options={ + "use_webhook": True, + }, ) diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 6629ba5730b..dca9fbc6437 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -6,7 +6,7 @@ from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import call_webhook, setup_integration +from . import call_webhook, enable_webhooks, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry @@ -21,6 +21,7 @@ async def test_binary_sensor( hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" + await enable_webhooks(hass) await setup_integration(hass, config_entry) client = await hass_client_no_auth() diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py deleted file mode 100644 index 80f5700d64c..00000000000 --- a/tests/components/withings/test_common.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for the Withings component.""" -from http import HTTPStatus -import re -from typing import Any -from unittest.mock import MagicMock -from urllib.parse import urlparse - -from aiohttp.test_utils import TestClient -import pytest -import requests_mock -from withings_api.common import NotifyAppli - -from homeassistant.components.withings.common import ConfigEntryWithingsApi -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation - -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config - -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - - -async def test_config_entry_withings_api(hass: HomeAssistant) -> None: - """Test ConfigEntryWithingsApi.""" - config_entry = MockConfigEntry( - data={"token": {"access_token": "mock_access_token", "expires_at": 1111111}} - ) - config_entry.add_to_hass(hass) - - implementation_mock = MagicMock(spec=AbstractOAuth2Implementation) - implementation_mock.async_refresh_token.return_value = { - "expires_at": 1111111, - "access_token": "mock_access_token", - } - - with requests_mock.mock() as rqmck: - rqmck.get( - re.compile(".*"), - status_code=HTTPStatus.OK, - json={"status": 0, "body": {"message": "success"}}, - ) - - api = ConfigEntryWithingsApi(hass, config_entry, implementation_mock) - response = await hass.async_add_executor_job( - api.request, "test", {"arg1": "val1", "arg2": "val2"} - ) - assert response == {"message": "success"} - - -@pytest.mark.parametrize( - ("user_id", "arg_user_id", "arg_appli", "expected_code"), - [ - [0, 0, NotifyAppli.WEIGHT.value, 0], # Success - [0, None, 1, 0], # Success, we ignore the user_id. - [0, None, None, 12], # No request body. - [0, "GG", None, 20], # appli not provided. - [0, 0, None, 20], # appli not provided. - [0, 0, 99, 21], # Invalid appli. - [0, 11, NotifyAppli.WEIGHT.value, 0], # Success, we ignore the user_id - ], -) -async def test_webhook_post( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - user_id: int, - arg_user_id: Any, - arg_appli: Any, - expected_code: int, - current_request_with_host: None, -) -> None: - """Test webhook callback.""" - person0 = new_profile_config("person0", user_id) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - - post_data = {} - if arg_user_id is not None: - post_data["userid"] = arg_user_id - if arg_appli is not None: - post_data["appli"] = arg_appli - - resp = await client.post( - urlparse(data_manager.webhook_config.url).path, data=post_data - ) - - # Wait for remaining tasks to complete. - await hass.async_block_till_done() - - data = await resp.json() - resp.close() - - assert data["code"] == expected_code diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 360766e0286..768f6fed16d 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,13 +1,14 @@ """Tests for config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from homeassistant.components.withings.const import DOMAIN, PROFILE -from homeassistant.config_entries import SOURCE_USER +from homeassistant.components.withings.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import CLIENT_ID +from . import setup_integration +from .conftest import CLIENT_ID, USER_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -63,18 +64,12 @@ async def test_full_flow( "homeassistant.components.withings.async_setup_entry", return_value=True ) as mock_setup: result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "profile" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk"} - ) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Henk" + assert result["title"] == "Withings" assert "result" in result assert result["result"].unique_id == "600" assert "token" in result["result"].data @@ -86,12 +81,13 @@ async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, current_request_with_host: None, + withings: AsyncMock, + config_entry: MockConfigEntry, disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={PROFILE: "Henk"}, unique_id="0") - config_entry.add_to_hass(hass) + await setup_integration(hass, config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -126,28 +122,13 @@ async def test_config_non_unique_profile( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": 10, + "userid": USER_ID, }, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "profile" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk"} - ) - - assert result - assert result["errors"]["base"] == "already_configured" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk 2"} - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Henk 2" - assert "result" in result - assert result["result"].unique_id == "10" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_config_reauth_profile( @@ -155,18 +136,22 @@ async def test_config_reauth_profile( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, + withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: - """Test reauth an existing profile re-creates the config entry.""" - config_entry.add_to_hass(hass) + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, config_entry) - config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - result = flows[0] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -198,12 +183,75 @@ async def test_config_reauth_profile( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": "0", + "userid": USER_ID, }, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_config_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + withings: AsyncMock, + disable_webhook_delay, + current_request_with_host, +) -> None: + """Test reauth with wrong account.""" + await setup_integration(hass, config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + 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.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": 12346, + }, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index acd21886e78..4e7eb812f0a 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,31 +1,20 @@ """Tests for the Withings component.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock from urllib.parse import urlparse import pytest import voluptuous as vol -from withings_api.common import NotifyAppli, UnauthorizedException +from withings_api.common import NotifyAppli -import homeassistant.components.webhook as webhook from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const -from homeassistant.components.withings.common import ConfigEntryWithingsApi, DataManager -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.setup import async_setup_component +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import setup_integration -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config +from . import enable_webhooks, setup_integration from .conftest import WEBHOOK_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -113,126 +102,6 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None: hass.async_create_task.assert_not_called() -@pytest.mark.parametrize( - "exception", - [ - UnauthorizedException("401"), - UnauthorizedException("401"), - Exception("401, this is the message"), - ], -) -@patch("homeassistant.components.withings.common._RETRY_COEFFICIENT", 0) -async def test_auth_failure( - hass: HomeAssistant, - component_factory: ComponentFactory, - exception: Exception, - current_request_with_host: None, -) -> None: - """Test auth failure.""" - person0 = new_profile_config( - "person0", - 0, - api_response_user_get_device=exception, - api_response_measure_get_meas=exception, - api_response_sleep_get_summary=exception, - ) - - await component_factory.configure_component(profile_configs=(person0,)) - assert not hass.config_entries.flow.async_progress() - - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - await data_manager.poll_data_update_coordinator.async_refresh() - - flows = hass.config_entries.flow.async_progress() - assert flows - assert len(flows) == 1 - - flow = flows[0] - assert flow["handler"] == const.DOMAIN - - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result - assert result["type"] == "external" - assert result["handler"] == const.DOMAIN - assert result["step_id"] == "auth" - - await component_factory.unload(person0) - - -async def test_set_config_unique_id( - hass: HomeAssistant, component_factory: ComponentFactory -) -> None: - """Test upgrading configs to use a unique id.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": {"userid": "my_user_id"}, - "auth_implementation": "withings", - "profile": person0.profile, - }, - ) - - with patch("homeassistant.components.withings.async_get_data_manager") as mock: - data_manager: DataManager = MagicMock(spec=DataManager) - data_manager.poll_data_update_coordinator = MagicMock( - spec=DataUpdateCoordinator - ) - data_manager.poll_data_update_coordinator.last_update_success = True - data_manager.subscription_update_coordinator = MagicMock( - spec=DataUpdateCoordinator - ) - data_manager.subscription_update_coordinator.last_update_success = True - mock.return_value = data_manager - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.unique_id == "my_user_id" - - -async def test_set_convert_unique_id_to_string(hass: HomeAssistant) -> None: - """Test upgrading configs to use a unique id.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": {"userid": 1234}, - "auth_implementation": "withings", - "profile": "person0", - }, - ) - config_entry.add_to_hass(hass) - - hass_config = { - HA_DOMAIN: { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, - }, - } - - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - spec=ConfigEntryWithingsApi, - ): - await async_process_ha_core_config(hass, hass_config.get(HA_DOMAIN)) - assert await async_setup_component(hass, HA_DOMAIN, {}) - assert await async_setup_component(hass, webhook.DOMAIN, hass_config) - assert await async_setup_component(hass, const.DOMAIN, hass_config) - await hass.async_block_till_done() - - assert config_entry.unique_id == "1234" - - async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, @@ -241,6 +110,7 @@ async def test_data_manager_webhook_subscription( hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" + await enable_webhooks(hass) await setup_integration(hass, config_entry) await hass_client_no_auth() await hass.async_block_till_done() @@ -285,3 +155,87 @@ async def test_requests( path=urlparse(webhook_url).path, ) assert response.status == 200 + + +@pytest.mark.parametrize( + ("config_entry"), + [ + MockConfigEntry( + domain=DOMAIN, + unique_id="123", + data={ + "token": {"userid": 123}, + "profile": "henk", + "use_webhook": False, + "webhook_id": "3290798afaebd28519c4883d3d411c7197572e0cc9b8d507471f59a700a61a55", + }, + ), + MockConfigEntry( + domain=DOMAIN, + unique_id="123", + data={ + "token": {"userid": 123}, + "profile": "henk", + "use_webhook": False, + }, + ), + ], +) +async def test_config_flow_upgrade( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test config flow upgrade.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(config_entry.entry_id) + + assert entry.unique_id == "123" + assert entry.data["token"]["userid"] == 123 + assert CONF_WEBHOOK_ID in entry.data + assert entry.options == { + "use_webhook": False, + } + + +@pytest.mark.parametrize( + ("body", "expected_code"), + [ + [{"userid": 0, "appli": NotifyAppli.WEIGHT.value}, 0], # Success + [{"userid": None, "appli": 1}, 0], # Success, we ignore the user_id. + [{}, 12], # No request body. + [{"userid": "GG"}, 20], # appli not provided. + [{"userid": 0}, 20], # appli not provided. + [{"userid": 0, "appli": 99}, 21], # Invalid appli. + [ + {"userid": 11, "appli": NotifyAppli.WEIGHT.value}, + 0, + ], # Success, we ignore the user_id + ], +) +async def test_webhook_post( + hass: HomeAssistant, + withings: AsyncMock, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + disable_webhook_delay, + body: dict[str, Any], + expected_code: int, + current_request_with_host: None, +) -> None: + """Test webhook callback.""" + await setup_integration(hass, config_entry) + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + resp = await client.post(urlparse(webhook_url).path, data=body) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data = await resp.json() + resp.close() + + assert data["code"] == expected_code From c3a7aee48e1bb7706106659b72d6c23067888ab0 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 13 Sep 2023 10:04:34 -0400 Subject: [PATCH 513/984] Bump pyenphase to 1.11.4 (#100288) --- 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 aa801fea14e..917e325be51 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.11.3"], + "requirements": ["pyenphase==1.11.4"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index c27c11b6a3e..a9c7116ad3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1675,7 +1675,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.3 +pyenphase==1.11.4 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aceab8ce5a5..e0901e29357 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1245,7 +1245,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.3 +pyenphase==1.11.4 # homeassistant.components.everlights pyeverlights==0.1.0 From f2f45380a98b460ada24dc6e0957ec14db7e1b66 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 16:34:14 +0200 Subject: [PATCH 514/984] Use shorthand attrs in iaqualink (#100281) * Use shorthand attrs in iaqualink * Use super * Update homeassistant/components/iaqualink/light.py Co-authored-by: Joost Lekkerkerker * Remove self * More follow ups * Remove cast and type check * Update homeassistant/components/iaqualink/__init__.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/iaqualink/__init__.py | 28 ++++-------- .../components/iaqualink/binary_sensor.py | 19 ++++---- homeassistant/components/iaqualink/climate.py | 34 ++++++--------- homeassistant/components/iaqualink/light.py | 43 ++++++------------- homeassistant/components/iaqualink/sensor.py | 33 ++++++-------- homeassistant/components/iaqualink/switch.py | 31 ++++++------- 6 files changed, 71 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 9554d30df45..fceb0d72213 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar, cast +from typing import Any, Concatenate, ParamSpec, TypeVar import httpx from iaqualink.client import AqualinkClient @@ -215,6 +215,14 @@ class AqualinkEntity(Entity): def __init__(self, dev: AqualinkDevice) -> None: """Initialize the entity.""" self.dev = dev + self._attr_unique_id = f"{dev.system.serial}_{dev.name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=dev.manufacturer, + model=dev.model, + name=dev.label, + via_device=(DOMAIN, dev.system.serial), + ) async def async_added_to_hass(self) -> None: """Set up a listener when this entity is added to HA.""" @@ -222,11 +230,6 @@ class AqualinkEntity(Entity): async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) - @property - def unique_id(self) -> str: - """Return a unique identifier for this entity.""" - return f"{self.dev.system.serial}_{self.dev.name}" - @property def assumed_state(self) -> bool: """Return whether the state is based on actual reading from the device.""" @@ -236,16 +239,3 @@ class AqualinkEntity(Entity): def available(self) -> bool: """Return whether the device is available or not.""" return self.dev.system.online is True - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer=self.dev.manufacturer, - model=self.dev.model, - # Instead of setting the device name to the entity name, iaqualink - # should be updated to set has_entity_name = True - name=cast(str | None, self.name), - via_device=(DOMAIN, self.dev.system.serial), - ) diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 7513a15272c..149261f97fc 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -1,6 +1,8 @@ """Support for Aqualink temperature sensors.""" from __future__ import annotations +from iaqualink.device import AqualinkBinarySensor + from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDeviceClass, @@ -31,19 +33,14 @@ async def async_setup_entry( class HassAqualinkBinarySensor(AqualinkEntity, BinarySensorEntity): """Representation of a binary sensor.""" - @property - def name(self) -> str: - """Return the name of the binary sensor.""" - return self.dev.label + def __init__(self, dev: AqualinkBinarySensor) -> None: + """Initialize AquaLink binary sensor.""" + super().__init__(dev) + self._attr_name = dev.label + if dev.label == "Freeze Protection": + self._attr_device_class = BinarySensorDeviceClass.COLD @property def is_on(self) -> bool: """Return whether the binary sensor is on or not.""" return self.dev.is_on - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of the binary sensor.""" - if self.name == "Freeze Protection": - return BinarySensorDeviceClass.COLD - return None diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 7c67dbdea4b..b7dbe43fca9 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -4,6 +4,8 @@ from __future__ import annotations import logging from typing import Any +from iaqualink.device import AqualinkThermostat + from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, ClimateEntity, @@ -42,10 +44,17 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - @property - def name(self) -> str: - """Return the name of the thermostat.""" - return self.dev.label.split(" ")[0] + def __init__(self, dev: AqualinkThermostat) -> None: + """Initialize AquaLink thermostat.""" + super().__init__(dev) + self._attr_name = dev.label.split(" ")[0] + self._attr_temperature_unit = ( + UnitOfTemperature.FAHRENHEIT + if dev.unit == "F" + else UnitOfTemperature.CELSIUS + ) + self._attr_min_temp = dev.min_temperature + self._attr_max_temp = dev.max_temperature @property def hvac_mode(self) -> HVACMode: @@ -64,23 +73,6 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - if self.dev.unit == "F": - return UnitOfTemperature.FAHRENHEIT - return UnitOfTemperature.CELSIUS - - @property - def min_temp(self) -> int: - """Return the minimum temperature supported by the thermostat.""" - return self.dev.min_temperature - - @property - def max_temp(self) -> int: - """Return the minimum temperature supported by the thermostat.""" - return self.dev.max_temperature - @property def target_temperature(self) -> float: """Return the current target temperature.""" diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 8b83f701915..3a166ba593d 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from iaqualink.device import AqualinkLight + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, @@ -37,10 +39,18 @@ async def async_setup_entry( class HassAqualinkLight(AqualinkEntity, LightEntity): """Representation of a light.""" - @property - def name(self) -> str: - """Return the name of the light.""" - return self.dev.label + def __init__(self, dev: AqualinkLight) -> None: + """Initialize AquaLink light.""" + super().__init__(dev) + self._attr_name = dev.label + if dev.supports_effect: + self._attr_effect_list = list(dev.supported_effects) + self._attr_supported_features = LightEntityFeature.EFFECT + color_mode = ColorMode.ONOFF + if dev.supports_brightness: + color_mode = ColorMode.BRIGHTNESS + self._attr_color_mode = color_mode + self._attr_supported_color_modes = {color_mode} @property def is_on(self) -> bool: @@ -81,28 +91,3 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): def effect(self) -> str: """Return the current light effect if supported.""" return self.dev.effect - - @property - def effect_list(self) -> list[str]: - """Return supported light effects.""" - return list(self.dev.supported_effects) - - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if self.dev.supports_brightness: - return ColorMode.BRIGHTNESS - return ColorMode.ONOFF - - @property - def supported_color_modes(self) -> set[ColorMode]: - """Flag supported color modes.""" - return {self.color_mode} - - @property - def supported_features(self) -> LightEntityFeature: - """Return the list of features supported by the light.""" - if self.dev.supports_effect: - return LightEntityFeature.EFFECT - - return LightEntityFeature(0) diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 8086aa29ee0..b18a85a43a5 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -1,6 +1,8 @@ """Support for Aqualink temperature sensors.""" from __future__ import annotations +from iaqualink.device import AqualinkSensor + from homeassistant.components.sensor import DOMAIN, SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature @@ -28,19 +30,17 @@ async def async_setup_entry( class HassAqualinkSensor(AqualinkEntity, SensorEntity): """Representation of a sensor.""" - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self.dev.label - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the measurement unit for the sensor.""" - if self.dev.name.endswith("_temp"): - if self.dev.system.temp_unit == "F": - return UnitOfTemperature.FAHRENHEIT - return UnitOfTemperature.CELSIUS - return None + def __init__(self, dev: AqualinkSensor) -> None: + """Initialize AquaLink sensor.""" + super().__init__(dev) + self._attr_name = dev.label + if dev.name.endswith("_temp"): + self._attr_native_unit_of_measurement = ( + UnitOfTemperature.FAHRENHEIT + if dev.system.temp_unit == "F" + else UnitOfTemperature.CELSIUS + ) + self._attr_device_class = SensorDeviceClass.TEMPERATURE @property def native_value(self) -> int | float | None: @@ -52,10 +52,3 @@ class HassAqualinkSensor(AqualinkEntity, SensorEntity): return int(self.dev.state) except ValueError: return float(self.dev.state) - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of the sensor.""" - if self.dev.name.endswith("_temp"): - return SensorDeviceClass.TEMPERATURE - return None diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index 8f482e8730f..590fcd61419 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from iaqualink.device import AqualinkSwitch + from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -30,23 +32,18 @@ async def async_setup_entry( class HassAqualinkSwitch(AqualinkEntity, SwitchEntity): """Representation of a switch.""" - @property - def name(self) -> str: - """Return the name of the switch.""" - return self.dev.label - - @property - def icon(self) -> str | None: - """Return an icon based on the switch type.""" - if self.name == "Cleaner": - return "mdi:robot-vacuum" - if self.name == "Waterfall" or self.name.endswith("Dscnt"): - return "mdi:fountain" - if self.name.endswith("Pump") or self.name.endswith("Blower"): - return "mdi:fan" - if self.name.endswith("Heater"): - return "mdi:radiator" - return None + def __init__(self, dev: AqualinkSwitch) -> None: + """Initialize AquaLink switch.""" + super().__init__(dev) + name = self._attr_name = dev.label + if name == "Cleaner": + self._attr_icon = "mdi:robot-vacuum" + elif name == "Waterfall" or name.endswith("Dscnt"): + self._attr_icon = "mdi:fountain" + elif name.endswith("Pump") or name.endswith("Blower"): + self._attr_icon = "mdi:fan" + if name.endswith("Heater"): + self._attr_icon = "mdi:radiator" @property def is_on(self) -> bool: From 871800778f39812b3fe9af7b8ac2d82eb63b0a74 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 16:50:33 +0200 Subject: [PATCH 515/984] Use shorthand attrs for velux (#100294) * Use shorthand attrs for velux * Update homeassistant/components/velux/cover.py Co-authored-by: Joost Lekkerkerker * black --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/velux/__init__.py | 18 +++------- homeassistant/components/velux/cover.py | 39 ++++++++++++---------- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 90045358136..ef552573115 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,7 +1,7 @@ """Support for VELUX KLF 200 devices.""" import logging -from pyvlx import PyVLX, PyVLXException +from pyvlx import OpeningDevice, PyVLX, PyVLXException import voluptuous as vol from homeassistant.const import ( @@ -90,9 +90,11 @@ class VeluxEntity(Entity): _attr_should_poll = False - def __init__(self, node): + def __init__(self, node: OpeningDevice) -> None: """Initialize the Velux device.""" self.node = node + self._attr_unique_id = node.serial_number + self._attr_name = node.name if node.name else "#" + str(node.node_id) @callback def async_register_callbacks(self): @@ -107,15 +109,3 @@ class VeluxEntity(Entity): async def async_added_to_hass(self): """Store register state change callback.""" self.async_register_callbacks() - - @property - def unique_id(self) -> str: - """Return the unique id base on the serial_id returned by Velux.""" - return self.node.serial_number - - @property - def name(self): - """Return the name of the Velux device.""" - if not self.node.name: - return "#" + str(self.node.node_id) - return self.node.name diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index c924fe5c10b..48c09a2b3c2 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -39,6 +39,26 @@ async def async_setup_platform( class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" + _is_blind = False + + def __init__(self, node: OpeningDevice) -> None: + """Initialize VeluxCover.""" + super().__init__(node) + self._attr_device_class = CoverDeviceClass.WINDOW + if isinstance(node, Awning): + self._attr_device_class = CoverDeviceClass.AWNING + if isinstance(node, Blind): + self._attr_device_class = CoverDeviceClass.BLIND + self._is_blind = True + if isinstance(node, GarageDoor): + self._attr_device_class = CoverDeviceClass.GARAGE + if isinstance(node, Gate): + self._attr_device_class = CoverDeviceClass.GATE + if isinstance(node, RollerShutter): + self._attr_device_class = CoverDeviceClass.SHUTTER + if isinstance(node, Window): + self._attr_device_class = CoverDeviceClass.WINDOW + @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" @@ -65,27 +85,10 @@ class VeluxCover(VeluxEntity, CoverEntity): @property def current_cover_tilt_position(self) -> int | None: """Return the current position of the cover.""" - if isinstance(self.node, Blind): + if self._is_blind: return 100 - self.node.orientation.position_percent return None - @property - def device_class(self) -> CoverDeviceClass: - """Define this cover as either awning, blind, garage, gate, shutter or window.""" - if isinstance(self.node, Awning): - return CoverDeviceClass.AWNING - if isinstance(self.node, Blind): - return CoverDeviceClass.BLIND - if isinstance(self.node, GarageDoor): - return CoverDeviceClass.GARAGE - if isinstance(self.node, Gate): - return CoverDeviceClass.GATE - if isinstance(self.node, RollerShutter): - return CoverDeviceClass.SHUTTER - if isinstance(self.node, Window): - return CoverDeviceClass.WINDOW - return CoverDeviceClass.WINDOW - @property def is_closed(self) -> bool: """Return if the cover is closed.""" From d0feb063ec3c5ec94502a7406c2811c4e47f61d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 10:03:36 -0500 Subject: [PATCH 516/984] Fix missing super async_added_to_hass in lookin (#100296) --- homeassistant/components/lookin/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index d20a21bd23c..0e518ffc1e5 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -182,6 +182,7 @@ class LookinPowerPushRemoteEntity(LookinPowerEntity): async def async_added_to_hass(self) -> None: """Call when the entity is added to hass.""" + await super().async_added_to_hass() self.async_on_remove( self._lookin_udp_subs.subscribe_event( self._lookin_device.id, From 6057fe5926f4ab2a2c8b7e0c2d94e455d668a30d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 18:05:17 +0200 Subject: [PATCH 517/984] Replace StateMachine._domain_index with a UserDict (#100270) * Replace StateMachine._domain_index with a UserDict * Access the UserDict's backing dict directly * Optimize --- homeassistant/core.py | 102 +++++++++++++++++++++++++++++++----------- tests/test_core.py | 27 +++++++---- 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 17b8b5f2e85..cbfc8097c7f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -6,7 +6,16 @@ of entities and react to changes. from __future__ import annotations import asyncio -from collections.abc import Callable, Collection, Coroutine, Iterable, Mapping +from collections import UserDict, defaultdict +from collections.abc import ( + Callable, + Collection, + Coroutine, + Iterable, + KeysView, + Mapping, + ValuesView, +) import concurrent.futures from contextlib import suppress import datetime @@ -1413,15 +1422,59 @@ class State: ) +class States(UserDict[str, State]): + """Container for states, maps entity_id -> State. + + Maintains an additional index: + - domain -> dict[str, State] + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._domain_index: defaultdict[str, dict[str, State]] = defaultdict(dict) + + def values(self) -> ValuesView[State]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, key: str, entry: State) -> None: + """Add an item.""" + self.data[key] = entry + self._domain_index[entry.domain][entry.entity_id] = entry + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + entry = self[key] + del self._domain_index[entry.domain][entry.entity_id] + super().__delitem__(key) + + def domain_entity_ids(self, key: str) -> KeysView[str] | tuple[()]: + """Get all entity_ids for a domain.""" + # Avoid polluting _domain_index with non-existing domains + if key not in self._domain_index: + return () + return self._domain_index[key].keys() + + def domain_states(self, key: str) -> ValuesView[State] | tuple[()]: + """Get all states for a domain.""" + # Avoid polluting _domain_index with non-existing domains + if key not in self._domain_index: + return () + return self._domain_index[key].values() + + class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_domain_index", "_reservations", "_bus", "_loop") + __slots__ = ("_states", "_states_data", "_reservations", "_bus", "_loop") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" - self._states: dict[str, State] = {} - self._domain_index: dict[str, dict[str, State]] = {} + self._states = States() + # _states_data is used to access the States backing dict directly to speed + # up read operations + self._states_data = self._states.data self._reservations: set[str] = set() self._bus = bus self._loop = loop @@ -1442,16 +1495,15 @@ class StateMachine: This method must be run in the event loop. """ if domain_filter is None: - return list(self._states) + return list(self._states_data) if isinstance(domain_filter, str): - return list(self._domain_index.get(domain_filter.lower(), ())) + return list(self._states.domain_entity_ids(domain_filter.lower())) - states: list[str] = [] + entity_ids: list[str] = [] for domain in domain_filter: - if domain_index := self._domain_index.get(domain): - states.extend(domain_index) - return states + entity_ids.extend(self._states.domain_entity_ids(domain)) + return entity_ids @callback def async_entity_ids_count( @@ -1462,12 +1514,14 @@ class StateMachine: This method must be run in the event loop. """ if domain_filter is None: - return len(self._states) + return len(self._states_data) if isinstance(domain_filter, str): - return len(self._domain_index.get(domain_filter.lower(), ())) + return len(self._states.domain_entity_ids(domain_filter.lower())) - return sum(len(self._domain_index.get(domain, ())) for domain in domain_filter) + return sum( + len(self._states.domain_entity_ids(domain)) for domain in domain_filter + ) def all(self, domain_filter: str | Iterable[str] | None = None) -> list[State]: """Create a list of all states.""" @@ -1484,15 +1538,14 @@ class StateMachine: This method must be run in the event loop. """ if domain_filter is None: - return list(self._states.values()) + return list(self._states_data.values()) if isinstance(domain_filter, str): - return list(self._domain_index.get(domain_filter.lower(), {}).values()) + return list(self._states.domain_states(domain_filter.lower())) states: list[State] = [] for domain in domain_filter: - if domain_index := self._domain_index.get(domain): - states.extend(domain_index.values()) + states.extend(self._states.domain_states(domain)) return states def get(self, entity_id: str) -> State | None: @@ -1500,7 +1553,7 @@ class StateMachine: Async friendly. """ - return self._states.get(entity_id.lower()) + return self._states_data.get(entity_id.lower()) def is_state(self, entity_id: str, state: str) -> bool: """Test if entity exists and is in specified state. @@ -1534,7 +1587,6 @@ class StateMachine: if old_state is None: return False - self._domain_index[old_state.domain].pop(entity_id) old_state.expire() self._bus.async_fire( EVENT_STATE_CHANGED, @@ -1579,7 +1631,7 @@ class StateMachine: entity_id are added. """ entity_id = entity_id.lower() - if entity_id in self._states or entity_id in self._reservations: + if entity_id in self._states_data or entity_id in self._reservations: raise HomeAssistantError( "async_reserve must not be called once the state is in the state" " machine." @@ -1591,7 +1643,9 @@ class StateMachine: def async_available(self, entity_id: str) -> bool: """Check to see if an entity_id is available to be used.""" entity_id = entity_id.lower() - return entity_id not in self._states and entity_id not in self._reservations + return ( + entity_id not in self._states_data and entity_id not in self._reservations + ) @callback def async_set( @@ -1614,7 +1668,7 @@ class StateMachine: entity_id = entity_id.lower() new_state = str(new_state) attributes = attributes or {} - if (old_state := self._states.get(entity_id)) is None: + if (old_state := self._states_data.get(entity_id)) is None: same_state = False same_attr = False last_changed = None @@ -1656,10 +1710,6 @@ class StateMachine: if old_state is not None: old_state.expire() self._states[entity_id] = state - if not (domain_index := self._domain_index.get(state.domain)): - domain_index = {} - self._domain_index[state.domain] = domain_index - domain_index[entity_id] = state self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": state}, diff --git a/tests/test_core.py b/tests/test_core.py index 5dcbb81db68..c5ce9eb0881 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,6 +15,7 @@ from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest +from pytest_unordered import unordered import voluptuous as vol from homeassistant.const import ( @@ -1031,17 +1032,18 @@ async def test_statemachine_is_state(hass: HomeAssistant) -> None: async def test_statemachine_entity_ids(hass: HomeAssistant) -> None: - """Test get_entity_ids method.""" + """Test async_entity_ids method.""" + assert hass.states.async_entity_ids() == [] + assert hass.states.async_entity_ids("light") == [] + assert hass.states.async_entity_ids(("light", "switch", "other")) == [] + hass.states.async_set("light.bowl", "on", {}) hass.states.async_set("SWITCH.AC", "off", {}) - ent_ids = hass.states.async_entity_ids() - assert len(ent_ids) == 2 - assert "light.bowl" in ent_ids - assert "switch.ac" in ent_ids - - ent_ids = hass.states.async_entity_ids("light") - assert len(ent_ids) == 1 - assert "light.bowl" in ent_ids + assert hass.states.async_entity_ids() == unordered(["light.bowl", "switch.ac"]) + assert hass.states.async_entity_ids("light") == ["light.bowl"] + assert hass.states.async_entity_ids(("light", "switch", "other")) == unordered( + ["light.bowl", "switch.ac"] + ) states = sorted(state.entity_id for state in hass.states.async_all()) assert states == ["light.bowl", "switch.ac"] @@ -1902,6 +1904,9 @@ async def test_chained_logging_misses_log_timeout( async def test_async_all(hass: HomeAssistant) -> None: """Test async_all.""" + assert hass.states.async_all() == [] + assert hass.states.async_all("light") == [] + assert hass.states.async_all(["light", "switch"]) == [] hass.states.async_set("switch.link", "on") hass.states.async_set("light.bowl", "on") @@ -1926,6 +1931,10 @@ async def test_async_all(hass: HomeAssistant) -> None: async def test_async_entity_ids_count(hass: HomeAssistant) -> None: """Test async_entity_ids_count.""" + assert hass.states.async_entity_ids_count() == 0 + assert hass.states.async_entity_ids_count("light") == 0 + assert hass.states.async_entity_ids_count({"light", "vacuum"}) == 0 + hass.states.async_set("switch.link", "on") hass.states.async_set("light.bowl", "on") hass.states.async_set("light.frog", "on") From f6b094dfee5be794ebe3b224a532d0182b3e1b09 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Sep 2023 18:08:15 +0200 Subject: [PATCH 518/984] Add options flow to Withings (#100300) --- .../components/withings/config_flow.py | 32 ++++++++++++++++++- .../components/withings/strings.json | 9 ++++++ tests/components/withings/test_config_flow.py | 30 ++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 7bbf869069f..f25ef95210c 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,10 +5,12 @@ from collections.abc import Mapping import logging from typing import Any +import voluptuous as vol from withings_api.common import AuthScope -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry from homeassistant.const import CONF_TOKEN +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -24,6 +26,14 @@ class WithingsFlowHandler( reauth_entry: ConfigEntry | None = None + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WithingsOptionsFlowHandler: + """Get the options flow for this handler.""" + return WithingsOptionsFlowHandler(config_entry) + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -77,3 +87,23 @@ class WithingsFlowHandler( return self.async_abort(reason="reauth_successful") return self.async_abort(reason="wrong_account") + + +class WithingsOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Withings Options flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize form.""" + if user_input is not None: + return self.async_create_entry( + data=user_input, + ) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_USE_WEBHOOK): bool}), + self.options, + ), + ) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 424a0edadce..5fa155a1c1c 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -28,6 +28,15 @@ "default": "Successfully authenticated with Withings." } }, + "options": { + "step": { + "init": { + "data": { + "use_webhook": "Use webhooks" + } + } + } + }, "entity": { "binary_sensor": { "in_bed": { diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 768f6fed16d..52a584e2513 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for config flow.""" from unittest.mock import AsyncMock, patch -from homeassistant.components.withings.const import DOMAIN +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -255,3 +255,31 @@ async def test_config_reauth_wrong_account( assert result assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_account" + + +async def test_options_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + withings: AsyncMock, + disable_webhook_delay, + current_request_with_host, +) -> None: + """Test options flow.""" + await setup_integration(hass, config_entry) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_WEBHOOK: True}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_USE_WEBHOOK: True} From ee65aa91e86c2976ab1301e67cea76c9126dd065 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 13 Sep 2023 18:09:12 +0200 Subject: [PATCH 519/984] Allow setting the elevation in `set_location` (#99978) Co-authored-by: G Johansson --- .../components/homeassistant/__init__.py | 21 +++++++++++++++---- .../components/homeassistant/services.yaml | 5 +++++ .../components/homeassistant/strings.json | 4 ++++ homeassistant/const.py | 3 +++ tests/components/homeassistant/test_init.py | 11 ++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 987a4317ba8..e4032ad954d 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -9,6 +9,7 @@ from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL from homeassistant.components import persistent_notification import homeassistant.config as conf_util from homeassistant.const import ( + ATTR_ELEVATION, ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, @@ -250,16 +251,28 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no async def async_set_location(call: ha.ServiceCall) -> None: """Service handler to set location.""" - await hass.config.async_update( - latitude=call.data[ATTR_LATITUDE], longitude=call.data[ATTR_LONGITUDE] - ) + service_data = { + "latitude": call.data[ATTR_LATITUDE], + "longitude": call.data[ATTR_LONGITUDE], + } + + if elevation := call.data.get(ATTR_ELEVATION): + service_data["elevation"] = elevation + + await hass.config.async_update(**service_data) async_register_admin_service( hass, ha.DOMAIN, SERVICE_SET_LOCATION, async_set_location, - vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), + vol.Schema( + { + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Optional(ATTR_ELEVATION): int, + } + ), ) async def async_handle_reload_templates(call: ha.ServiceCall) -> None: diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 899fee357fd..09a280133f2 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -13,6 +13,11 @@ set_location: example: 117.22743 selector: text: + elevation: + required: false + example: 120 + selector: + text: stop: toggle: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 5404ee4af64..53510a94f01 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -81,6 +81,10 @@ "longitude": { "name": "[%key:common::config_flow::data::longitude%]", "description": "Longitude of your location." + }, + "elevation": { + "name": "[%key:common::config_flow::data::elevation%]", + "description": "Elevation of your location." } } }, diff --git a/homeassistant/const.py b/homeassistant/const.py index 70f7827143b..de968451af9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -460,6 +460,9 @@ ATTR_HIDDEN: Final = "hidden" ATTR_LATITUDE: Final = "latitude" ATTR_LONGITUDE: Final = "longitude" +# Elevation of the entity +ATTR_ELEVATION: Final = "elevation" + # Accuracy of location in meters ATTR_GPS_ACCURACY: Final = "gps_accuracy" diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 652fc4a1fdd..4c5643ae3ca 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -305,6 +305,8 @@ async def test_setting_location(hass: HomeAssistant) -> None: # Just to make sure that we are updating values. assert hass.config.latitude != 30 assert hass.config.longitude != 40 + elevation = hass.config.elevation + assert elevation != 50 await hass.services.async_call( "homeassistant", "set_location", @@ -314,6 +316,15 @@ async def test_setting_location(hass: HomeAssistant) -> None: assert len(events) == 1 assert hass.config.latitude == 30 assert hass.config.longitude == 40 + assert hass.config.elevation == elevation + + await hass.services.async_call( + "homeassistant", + "set_location", + {"latitude": 30, "longitude": 40, "elevation": 50}, + blocking=True, + ) + assert hass.config.elevation == 50 async def test_require_admin( From c3d1cdd0e933134f47bfa82d6b4ccefab6b40429 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 18:09:34 +0200 Subject: [PATCH 520/984] Improve UserDict in device and entity registries (#100307) --- homeassistant/helpers/device_registry.py | 8 ++++---- homeassistant/helpers/entity_registry.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9c2492d65e8..64d102d020f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -392,14 +392,14 @@ class DeviceRegistryItems(UserDict[str, _EntryTypeT]): def __setitem__(self, key: str, entry: _EntryTypeT) -> None: """Add an item.""" - if key in self: - old_entry = self[key] + data = self.data + if key in data: + old_entry = data[key] for connection in old_entry.connections: del self._connections[connection] for identifier in old_entry.identifiers: del self._identifiers[identifier] - # type ignore linked to mypy issue: https://github.com/python/mypy/issues/13596 - super().__setitem__(key, entry) # type: ignore[assignment] + data[key] = entry for connection in entry.connections: self._connections[connection] = entry for identifier in entry.identifiers: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 939c8986e71..09f92a88882 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -450,11 +450,12 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): def __setitem__(self, key: str, entry: RegistryEntry) -> None: """Add an item.""" - if key in self: - old_entry = self[key] + data = self.data + if key in data: + old_entry = data[key] del self._entry_ids[old_entry.id] del self._index[(old_entry.domain, old_entry.platform, old_entry.unique_id)] - super().__setitem__(key, entry) + data[key] = entry self._entry_ids[entry.id] = entry self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id From 6a2dd4fe742b998402e0539b2f13b80461bb82e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 11:25:10 -0500 Subject: [PATCH 521/984] Bump yalexs to 1.9.0 (#100305) --- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index a2d460d12ec..c5a0da71136 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.8.0", "yalexs-ble==2.3.0"] + "requirements": ["yalexs==1.9.0", "yalexs-ble==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a9c7116ad3f..c16584b68f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2742,7 +2742,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.8.0 +yalexs==1.9.0 # homeassistant.components.yeelight yeelight==0.7.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0901e29357..d25cb5dbb33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2027,7 +2027,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.8.0 +yalexs==1.9.0 # homeassistant.components.yeelight yeelight==0.7.13 From d17957ac1a8c15ae0389247880db330c211d3834 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 13 Sep 2023 18:59:35 +0200 Subject: [PATCH 522/984] Update debugpy to 1.8.0 (#100311) --- homeassistant/components/debugpy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 4fe141c4943..d3ed3564344 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.6.7"] + "requirements": ["debugpy==1.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c16584b68f6..e077804a718 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -649,7 +649,7 @@ datapoint==0.9.8 dbus-fast==2.6.0 # homeassistant.components.debugpy -debugpy==1.6.7 +debugpy==1.8.0 # homeassistant.components.decora_wifi # decora-wifi==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d25cb5dbb33..71bcf0a2a19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -532,7 +532,7 @@ datapoint==0.9.8 dbus-fast==2.6.0 # homeassistant.components.debugpy -debugpy==1.6.7 +debugpy==1.8.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 0d33cba8235749cbd4030c59f510f521c68abcd2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 19:30:43 +0200 Subject: [PATCH 523/984] Use shorthand attrs in template integration (#100301) --- .../template/alarm_control_panel.py | 26 ++++----------- .../components/template/binary_sensor.py | 10 +----- homeassistant/components/template/cover.py | 33 ++++++------------- homeassistant/components/template/fan.py | 7 ++-- homeassistant/components/template/light.py | 19 +++++------ homeassistant/components/template/lock.py | 6 +--- homeassistant/components/template/sensor.py | 22 +++++++------ homeassistant/components/template/switch.py | 6 +--- 8 files changed, 42 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index af2e432c61e..2cac5d74a7a 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -154,8 +154,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): name = self._attr_name self._template = config.get(CONF_VALUE_TEMPLATE) self._disarm_script = None - self._code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] - self._code_format: TemplateCodeFormat = config[CONF_CODE_FORMAT] + self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] + self._attr_code_format = config[CONF_CODE_FORMAT].value if (disarm_action := config.get(CONF_DISARM_ACTION)) is not None: self._disarm_script = Script(hass, disarm_action, name, DOMAIN) self._arm_away_script = None @@ -183,14 +183,6 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): self._state: str | None = None - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> AlarmControlPanelEntityFeature: - """Return the list of supported features.""" supported_features = AlarmControlPanelEntityFeature(0) if self._arm_night_script is not None: supported_features = ( @@ -221,18 +213,12 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): supported_features = ( supported_features | AlarmControlPanelEntityFeature.TRIGGER ) - - return supported_features + self._attr_supported_features = supported_features @property - def code_format(self) -> CodeFormat | None: - """Regex for code format or None if no code is required.""" - return self._code_format.value - - @property - def code_arm_required(self) -> bool: - """Whether the code is required for arm actions.""" - return self._code_arm_required + def state(self) -> str | None: + """Return the state of the device.""" + return self._state @callback def _update_state(self, result): diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index ca0ed583d86..427fe6221cd 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -236,9 +235,7 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): ENTITY_ID_FORMAT, object_id, hass=hass ) - self._device_class: BinarySensorDeviceClass | None = config.get( - CONF_DEVICE_CLASS - ) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._template = config[CONF_STATE] self._state: bool | None = None self._delay_cancel = None @@ -321,11 +318,6 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): """Return true if sensor is on.""" return self._state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the sensor class of the binary sensor.""" - return self._device_class - class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): """Sensor entity based on trigger data.""" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 3a8e536f7f5..5daa4531109 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -12,7 +12,6 @@ from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -155,7 +154,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._template = config.get(CONF_VALUE_TEMPLATE) self._position_template = config.get(CONF_POSITION_TEMPLATE) self._tilt_template = config.get(CONF_TILT_TEMPLATE) - self._device_class: CoverDeviceClass | None = config.get(CONF_DEVICE_CLASS) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._open_script = None if (open_action := config.get(OPEN_ACTION)) is not None: self._open_script = Script(hass, open_action, friendly_name, DOMAIN) @@ -182,6 +181,15 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._is_closing = False self._tilt_value = None + supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + if self._stop_script is not None: + supported_features |= CoverEntityFeature.STOP + if self._position_script is not None: + supported_features |= CoverEntityFeature.SET_POSITION + if self._tilt_script is not None: + supported_features |= TILT_FEATURES + self._attr_supported_features = supported_features + @callback def _async_setup_templates(self) -> None: """Set up templates.""" @@ -318,27 +326,6 @@ class CoverTemplate(TemplateEntity, CoverEntity): """ return self._tilt_value - @property - def device_class(self) -> CoverDeviceClass | None: - """Return the device class of the cover.""" - return self._device_class - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - - if self._stop_script is not None: - supported_features |= CoverEntityFeature.STOP - - if self._position_script is not None: - supported_features |= CoverEntityFeature.SET_POSITION - - if self._tilt_script is not None: - supported_features |= TILT_FEATURES - - return supported_features - async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._open_script: diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index c07c680887b..d39fa56775a 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -195,6 +195,8 @@ class TemplateFan(TemplateEntity, FanEntity): if self._direction_template: self._attr_supported_features |= FanEntityFeature.DIRECTION + self._attr_assumed_state = self._template is None + @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" @@ -467,8 +469,3 @@ class TemplateFan(TemplateEntity, FanEntity): ", ".join(_VALID_DIRECTIONS), ) self._direction = None - - @property - def assumed_state(self) -> bool: - """State is assumed, if no template given.""" - return self._template is None diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 09f5054ed51..b3f276240b5 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -197,6 +197,12 @@ class LightTemplate(TemplateEntity, LightEntity): if len(self._supported_color_modes) == 1: self._fixed_color_mode = next(iter(self._supported_color_modes)) + self._attr_supported_features = LightEntityFeature(0) + if self._effect_script is not None: + self._attr_supported_features |= LightEntityFeature.EFFECT + if self._supports_transition is True: + self._attr_supported_features |= LightEntityFeature.TRANSITION + @property def brightness(self) -> int | None: """Return the brightness of the light.""" @@ -253,16 +259,6 @@ class LightTemplate(TemplateEntity, LightEntity): """Flag supported color modes.""" return self._supported_color_modes - @property - def supported_features(self) -> LightEntityFeature: - """Flag supported features.""" - supported_features = LightEntityFeature(0) - if self._effect_script is not None: - supported_features |= LightEntityFeature.EFFECT - if self._supports_transition is True: - supported_features |= LightEntityFeature.TRANSITION - return supported_features - @property def is_on(self) -> bool | None: """Return true if device is on.""" @@ -644,4 +640,7 @@ class LightTemplate(TemplateEntity, LightEntity): if render in (None, "None", ""): self._supports_transition = False return + self._attr_supported_features &= LightEntityFeature.EFFECT self._supports_transition = bool(render) + if self._supports_transition: + self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index d8c7127f0e6..de483971ac6 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -90,11 +90,7 @@ class TemplateLock(TemplateEntity, LockEntity): self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN) self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN) self._optimistic = config.get(CONF_OPTIMISTIC) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return bool(self._optimistic) + self._attr_assumed_state = bool(self._optimistic) @property def is_locked(self) -> bool: diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index cdd14921bc1..e757f561a7e 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -42,6 +42,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import TriggerUpdateCoordinator from .const import ( CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, @@ -274,6 +275,17 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): domain = SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize.""" + super().__init__(hass, coordinator, config) + self._attr_state_class = config.get(CONF_STATE_CLASS) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -293,16 +305,6 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Return state of the sensor.""" return self._rendered.get(CONF_STATE) - @property - def state_class(self) -> str | None: - """Sensor state class.""" - return self._config.get(CONF_STATE_CLASS) - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor, if any.""" - return self._config.get(CONF_UNIT_OF_MEASUREMENT) - @callback def _process_data(self) -> None: """Process new data.""" diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 39270d3fc6d..5e75eafe233 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -113,6 +113,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): self._on_script = Script(hass, config[ON_ACTION], friendly_name, DOMAIN) self._off_script = Script(hass, config[OFF_ACTION], friendly_name, DOMAIN) self._state: bool | None = False + self._attr_assumed_state = self._template is None @callback def _update_state(self, result): @@ -168,8 +169,3 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): if self._template is None: self._state = False self.async_write_ha_state() - - @property - def assumed_state(self) -> bool: - """State is assumed, if no template given.""" - return self._template is None From 23a891ebb1258c336008db505a394cb007557e3c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 13 Sep 2023 13:43:28 -0400 Subject: [PATCH 524/984] Update Roborock entity categories (#100316) --- homeassistant/components/roborock/select.py | 3 +++ homeassistant/components/roborock/sensor.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 2d76aac33d3..5cf71bb12f4 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -7,6 +7,7 @@ from roborock.roborock_typing import RoborockCommand 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.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -43,6 +44,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, value_fn=lambda data: data.water_box_mode.name, + entity_category=EntityCategory.CONFIG, options_lambda=lambda data: data.water_box_mode.keys() if data.water_box_mode else None, @@ -53,6 +55,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, value_fn=lambda data: data.mop_mode.name, + entity_category=EntityCategory.CONFIG, options_lambda=lambda data: data.mop_mode.keys() if data.mop_mode else None, parameter_lambda=lambda key, status: [status.mop_mode.as_dict().get(key)], ), diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 8d58ae96c45..fc2fa6a6e40 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -91,6 +91,7 @@ SENSOR_DESCRIPTIONS = [ translation_key="cleaning_time", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.status.clean_time, + entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -99,6 +100,7 @@ SENSOR_DESCRIPTIONS = [ icon="mdi:history", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.clean_summary.clean_time, + entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescription( key="status", @@ -114,6 +116,7 @@ SENSOR_DESCRIPTIONS = [ icon="mdi:texture-box", translation_key="cleaning_area", value_fn=lambda data: data.status.square_meter_clean_area, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=AREA_SQUARE_METERS, ), RoborockSensorDescription( @@ -121,6 +124,7 @@ SENSOR_DESCRIPTIONS = [ icon="mdi:texture-box", translation_key="total_cleaning_area", value_fn=lambda data: data.clean_summary.square_meter_clean_area, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=AREA_SQUARE_METERS, ), RoborockSensorDescription( From 7b00265cfe6ea36ba34e4dfc867a9cf0b1fc71ab Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 13 Sep 2023 20:14:03 +0200 Subject: [PATCH 525/984] Remove legacy UniFi PoE client clean up (#100318) --- homeassistant/components/unifi/__init__.py | 23 +------------ tests/components/unifi/test_switch.py | 40 +--------------------- 2 files changed, 2 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 10959b8965c..0bde41ac611 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -34,9 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up the UniFi Network integration.""" hass.data.setdefault(UNIFI_DOMAIN, {}) - # Removal of legacy PoE control was introduced with 2022.12 - async_remove_poe_client_entities(hass, config_entry) - try: api = await get_unifi_controller(hass, config_entry.data) controller = UniFiController(hass, config_entry, api) @@ -74,24 +71,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return await controller.async_reset() -@callback -def async_remove_poe_client_entities( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Remove PoE client entities.""" - ent_reg = er.async_get(hass) - - entity_ids_to_be_removed = [ - entry.entity_id - for entry in ent_reg.entities.values() - if entry.config_entry_id == config_entry.entry_id - and entry.unique_id.startswith("poe-") - ] - - for entity_id in entity_ids_to_be_removed: - ent_reg.async_remove(entity_id) - - class UnifiWirelessClients: """Class to store clients known to be wireless. diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index d376cab8add..8e536119291 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -6,7 +6,6 @@ from aiounifi.models.message import MessageKey from aiounifi.websocket import WebsocketState import pytest -from homeassistant import config_entries from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -34,12 +33,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .test_controller import ( - CONTROLLER_HOST, - ENTRY_CONFIG, - SITE, - setup_unifi_integration, -) +from .test_controller import CONTROLLER_HOST, SITE, setup_unifi_integration from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -1436,38 +1430,6 @@ async def test_poe_port_switches( assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF -async def test_remove_poe_client_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test old PoE client switches are removed.""" - - config_entry = config_entries.ConfigEntry( - version=1, - domain=UNIFI_DOMAIN, - title="Mock Title", - data=ENTRY_CONFIG, - source="test", - options={}, - entry_id="1", - ) - - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( - SWITCH_DOMAIN, - UNIFI_DOMAIN, - "poe-123", - config_entry=config_entry, - ) - - await setup_unifi_integration(hass, aioclient_mock) - - assert not [ - entry - for entry in ent_reg.entities.values() - if entry.config_entry_id == config_entry.entry_id - ] - - async def test_wlan_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket ) -> None: From 9631c0ba2b524cc9a2fd5ac0156dd6e9d995acfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 13:19:01 -0500 Subject: [PATCH 526/984] Use short hand attributes in onvif camera (#100319) see #95315 --- homeassistant/components/onvif/camera.py | 25 ++++++++---------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 96ce70344fd..013dd2e453f 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -113,29 +113,20 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ) self._stream_uri: str | None = None self._stream_uri_future: asyncio.Future[str] | None = None + self._attr_entity_registry_enabled_default = ( + device.max_resolution == profile.video.resolution.width + ) + if profile.index: + self._attr_unique_id = f"{self.mac_or_serial}_{profile.index}" + else: + self._attr_unique_id = self.mac_or_serial + self._attr_name = f"{device.name} {profile.name}" @property def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" return bool(self.stream and self.stream.dynamic_stream_settings.preload_stream) - @property - def name(self) -> str: - """Return the name of this camera.""" - return f"{self.device.name} {self.profile.name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - if self.profile.index: - return f"{self.mac_or_serial}_{self.profile.index}" - return self.mac_or_serial - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.device.max_resolution == self.profile.video.resolution.width - async def stream_source(self): """Return the stream source.""" return await self._async_get_stream_uri() From 8625bf7894bd2565b9cd8687ee0446d55c0cf44a Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 13 Sep 2023 20:22:47 +0200 Subject: [PATCH 527/984] Add some tests to Freebox (#99755) --- .../components/freebox/test_binary_sensor.py | 30 +++++------- tests/components/freebox/test_button.py | 49 +++++++++++++------ tests/components/freebox/test_sensor.py | 48 +++++++++++++++++- 3 files changed, 93 insertions(+), 34 deletions(-) diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index ec504a514ad..218ef953ee0 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -1,29 +1,24 @@ """Tests for the Freebox sensors.""" from copy import deepcopy -from datetime import timedelta from unittest.mock import Mock -from homeassistant.components.freebox.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.freebox import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util -from .const import DATA_STORAGE_GET_RAIDS, MOCK_HOST, MOCK_PORT +from .common import setup_platform +from .const import DATA_STORAGE_GET_RAIDS -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import async_fire_time_changed -async def test_raid_array_degraded(hass: HomeAssistant, router: Mock) -> None: +async def test_raid_array_degraded( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: """Test raid array degraded binary sensor.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - unique_id=MOCK_HOST, - ) - entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_platform(hass, BINARY_SENSOR_DOMAIN) assert ( hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state @@ -35,7 +30,8 @@ async def test_raid_array_degraded(hass: HomeAssistant, router: Mock) -> None: data_storage_get_raids_degraded[0]["degraded"] = True router().storage.get_raids.return_value = data_storage_get_raids_degraded # Simulate an update - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) # To execute the save await hass.async_block_till_done() assert ( diff --git a/tests/components/freebox/test_button.py b/tests/components/freebox/test_button.py index de15e90f54f..5f72b5968f1 100644 --- a/tests/components/freebox/test_button.py +++ b/tests/components/freebox/test_button.py @@ -1,29 +1,19 @@ """Tests for the Freebox config flow.""" -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch from pytest_unordered import unordered from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.freebox.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .const import MOCK_HOST, MOCK_PORT - -from tests.common import MockConfigEntry +from .common import setup_platform -async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: +async def test_reboot(hass: HomeAssistant, router: Mock) -> None: """Test reboot button.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - unique_id=MOCK_HOST, - ) - entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = await setup_platform(hass, BUTTON_DOMAIN) + assert hass.config_entries.async_entries() == unordered([entry, ANY]) assert router.call_count == 1 @@ -32,6 +22,7 @@ async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: with patch( "homeassistant.components.freebox.router.FreeboxRouter.reboot" ) as mock_service: + mock_service.assert_not_called() await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, @@ -42,3 +33,29 @@ async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: ) await hass.async_block_till_done() mock_service.assert_called_once() + + +async def test_mark_calls_as_read(hass: HomeAssistant, router: Mock) -> None: + """Test mark calls as read button.""" + entry = await setup_platform(hass, BUTTON_DOMAIN) + + assert hass.config_entries.async_entries() == unordered([entry, ANY]) + + assert router.call_count == 1 + assert router().open.call_count == 1 + + with patch( + "homeassistant.components.freebox.router.FreeboxRouter.call" + ) as mock_service: + mock_service.mark_calls_log_as_read = AsyncMock() + mock_service.mark_calls_log_as_read.assert_not_called() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + service_data={ + ATTR_ENTITY_ID: "button.mark_calls_as_read", + }, + blocking=True, + ) + await hass.async_block_till_done() + mock_service.mark_calls_log_as_read.assert_called_once() diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py index 41daa79fe4e..801e8508d86 100644 --- a/tests/components/freebox/test_sensor.py +++ b/tests/components/freebox/test_sensor.py @@ -9,11 +9,57 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_GET_NODES, DATA_STORAGE_GET_DISKS +from .const import ( + DATA_CONNECTION_GET_STATUS, + DATA_HOME_GET_NODES, + DATA_STORAGE_GET_DISKS, +) from tests.common import async_fire_time_changed +async def test_network_speed( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test missed call sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.freebox_download_speed").state == "198.9" + assert hass.states.get("sensor.freebox_upload_speed").state == "1440.0" + + # Simulate a changed speed + data_connection_get_status_changed = deepcopy(DATA_CONNECTION_GET_STATUS) + data_connection_get_status_changed["rate_down"] = 123400 + data_connection_get_status_changed["rate_up"] = 432100 + router().connection.get_status.return_value = data_connection_get_status_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_download_speed").state == "123.4" + assert hass.states.get("sensor.freebox_upload_speed").state == "432.1" + + +async def test_call( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test missed call sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.freebox_missed_calls").state == "3" + + # Simulate we marked calls as read + data_call_get_calls_marked_as_read = [] + router().call.get_calls_log.return_value = data_call_get_calls_marked_as_read + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_missed_calls").state == "0" + + async def test_disk( hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock ) -> None: From 9ceeadc7152acab0035936eefa3ef47476587272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 13 Sep 2023 21:09:29 +0200 Subject: [PATCH 528/984] Update Mill library to 0.11.5, handle rate limiting (#100315) --- 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 a1538bed5cf..561a24c29df 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.2", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.5", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e077804a718..1c4ef579020 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1217,7 +1217,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.2 +millheater==0.11.5 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71bcf0a2a19..fa126b6feb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -937,7 +937,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.2 +millheater==0.11.5 # homeassistant.components.minio minio==7.1.12 From d8d756dd7d0275a3ef8c735fd4f5da7dfff62a95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 14:33:42 -0500 Subject: [PATCH 529/984] Bump dbus-fast to 2.7.0 (#100321) --- 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 cd74d9b6c97..7908dbbad66 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", - "dbus-fast==2.6.0" + "dbus-fast==2.7.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ed972c39c2c..cf815b43b91 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.6.0 +dbus-fast==2.7.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.71.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1c4ef579020..58be5cdac44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.6.0 +dbus-fast==2.7.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa126b6feb8..82e2d21b943 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.6.0 +dbus-fast==2.7.0 # homeassistant.components.debugpy debugpy==1.8.0 From 877eedf6d7255da63febb88a4f5e38a5a2c65020 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 14:38:40 -0500 Subject: [PATCH 530/984] Use cached_property in entity_registry (#100302) --- homeassistant/helpers/entity_registry.py | 33 +++++++----------------- tests/syrupy.py | 5 +--- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 09f92a88882..42de4749215 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, TypeVar, import attr import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -148,7 +149,7 @@ def _protect_entity_options( return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) -@attr.s(slots=True, frozen=True) +@attr.s(frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -183,13 +184,6 @@ class RegistryEntry: translation_key: str | None = attr.ib(default=None) unit_of_measurement: str | None = attr.ib(default=None) - _partial_repr: str | None | UndefinedType = attr.ib( - cmp=False, default=UNDEFINED, init=False, repr=False - ) - _display_repr: str | None | UndefinedType = attr.ib( - cmp=False, default=UNDEFINED, init=False, repr=False - ) - @domain.default def _domain_default(self) -> str: """Compute domain value.""" @@ -231,21 +225,17 @@ class RegistryEntry: display_dict["dp"] = precision return display_dict - @property + @cached_property def display_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry. This version only includes what's needed for display. """ - if self._display_repr is not UNDEFINED: - return self._display_repr - try: dict_repr = self._as_display_dict json_repr: str | None = JSON_DUMP(dict_repr) if dict_repr else None - object.__setattr__(self, "_display_repr", json_repr) + return json_repr except (ValueError, TypeError): - object.__setattr__(self, "_display_repr", None) _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", self.entity_id, @@ -253,8 +243,8 @@ class RegistryEntry: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - # Mypy doesn't understand the __setattr__ business - return self._display_repr # type: ignore[return-value] + + return None @property def as_partial_dict(self) -> dict[str, Any]: @@ -278,17 +268,13 @@ class RegistryEntry: "unique_id": self.unique_id, } - @property + @cached_property def partial_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry.""" - if self._partial_repr is not UNDEFINED: - return self._partial_repr - try: dict_repr = self.as_partial_dict - object.__setattr__(self, "_partial_repr", JSON_DUMP(dict_repr)) + return JSON_DUMP(dict_repr) except (ValueError, TypeError): - object.__setattr__(self, "_partial_repr", None) _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", self.entity_id, @@ -296,8 +282,7 @@ class RegistryEntry: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - # Mypy doesn't understand the __setattr__ business - return self._partial_repr # type: ignore[return-value] + return None @callback def write_unavailable_state(self, hass: HomeAssistant) -> None: diff --git a/tests/syrupy.py b/tests/syrupy.py index c7d114a4812..4846e013f5d 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -166,7 +166,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): cls, data: er.RegistryEntry ) -> SerializableData: """Prepare a Home Assistant entity registry entry for serialization.""" - serialized = EntityRegistryEntrySnapshot( + return EntityRegistryEntrySnapshot( attrs.asdict(data) | { "config_entry_id": ANY, @@ -175,9 +175,6 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): "options": {k: dict(v) for k, v in data.options.items()}, } ) - serialized.pop("_partial_repr") - serialized.pop("_display_repr") - return serialized @classmethod def _serializable_flow_result(cls, data: FlowResult) -> SerializableData: From 7b204ca36b6a18bf6936d9238d5b687befd49b1c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 13 Sep 2023 22:00:29 +0200 Subject: [PATCH 531/984] Use snapshot assertion for nexia diagnostics test (#100328) --- .../nexia/snapshots/test_diagnostics.ambr | 10794 ++++++++++++++++ tests/components/nexia/test_diagnostics.py | 9107 +------------ 2 files changed, 10800 insertions(+), 9101 deletions(-) create mode 100644 tests/components/nexia/snapshots/test_diagnostics.ambr diff --git a/tests/components/nexia/snapshots/test_diagnostics.ambr b/tests/components/nexia/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f7a7df8854b --- /dev/null +++ b/tests/components/nexia/snapshots/test_diagnostics.ambr @@ -0,0 +1,10794 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'automations': list([ + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3467876', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=472ae0d2-5d7c-4a1c-9e47-4d9035fdace5', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3467876', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3467876', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Downstairs East Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Downstairs West Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Activate the mode named 'Away 12' AND Master Suite will permanently hold the heat to 62.0 and cool to 83.0", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'plane', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + ]), + 'id': 3467876, + 'name': 'Away for 12 Hours', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3467870', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=f63ee20c-3146-49a1-87c5-47429a063d15', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3467870', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3467870', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs East Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Activate the mode named 'Away 24' AND Master Suite will permanently hold the heat to 60.0 and cool to 85.0", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'plane', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + ]), + 'id': 3467870, + 'name': 'Away For 24 Hours', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3452469', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e5c59b93-efca-4937-9499-3f4c896ab17c', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3452469', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3452469', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 63.0 and cool to 80.0 AND Downstairs East Wing will permanently hold the heat to 63.0 and cool to 79.0 AND Downstairs West Wing will permanently hold the heat to 63.0 and cool to 79.0 AND Upstairs West Wing will permanently hold the heat to 63.0 and cool to 81.0 AND Upstairs West Wing will change Fan Mode to Auto AND Downstairs East Wing will change Fan Mode to Auto AND Downstairs West Wing will change Fan Mode to Auto AND Activate the mode named 'Away Short' AND Master Suite will permanently hold the heat to 63.0 and cool to 79.0 AND Master Suite will change Fan Mode to Auto", + 'enabled': False, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'key', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3452469, + 'name': 'Away Short', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3452472', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=861b9fec-d259-4492-a798-5712251666c4', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3452472', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3452472', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Activate the mode named 'Home' AND Master Suite will Run Schedule", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'at_home', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3452472, + 'name': 'Home', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3454776', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=96c71d37-66aa-4cbb-84ff-a90412fd366a', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3454776', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3454776', + }), + }), + 'description': 'When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs East Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Upstairs West Wing will change Fan Mode to Auto AND Downstairs East Wing will change Fan Mode to Auto AND Downstairs West Wing will change Fan Mode to Auto AND Master Suite will permanently hold the heat to 60.0 and cool to 85.0 AND Master Suite will change Fan Mode to Auto', + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3454776, + 'name': 'IFTTT Power Spike', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3454774', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=880c5287-d92c-4368-8494-e10975e92733', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3454774', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3454774', + }), + }), + 'description': 'When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Master Suite will Run Schedule', + 'enabled': False, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3454774, + 'name': 'IFTTT return to schedule', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3486078', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=d33c013b-2357-47a9-8c66-d2c3693173b0', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3486078', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3486078', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Downstairs East Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Downstairs West Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Activate the mode named 'Power Outage'", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'bell', + }), + ]), + 'id': 3486078, + 'name': 'Power Outage', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3486091', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=b9141df8-2e5e-4524-b8ef-efcbf48d775a', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3486091', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3486091', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Activate the mode named 'Home'", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'at_home', + }), + ]), + 'id': 3486091, + 'name': 'Power Restored', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + ]), + 'devices': list([ + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=cd9a70e8-fd0d-4b58-b071-05a202fd8953', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059661', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/be6d8ede5cac02fe8be18c334b04d539c9200fa9230eef63', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '000000', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261002', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261002&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + 'id': 83261002, + 'name': 'Living East', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 71, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261005', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261005&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83261005, + 'name': 'Kitchen', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261008', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261008&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83261008, + 'name': 'Down Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 78, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261011', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261011&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + 'id': 83261011, + 'name': 'Tech Room', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 78, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059661?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059661?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059661, + 'indoor_humidity': '36', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Downstairs East Wing', + 'name_editable': True, + 'outdoor_temperature': '88', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/dehumidify', + }), + }), + 'current_value': 0.5, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261002', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261002&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + 'id': 83261002, + 'name': 'Living East', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 71, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261005', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261005&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83261005, + 'name': 'Kitchen', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261008', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261008&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83261008, + 'name': 'Down Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 78, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261011', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261011&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + 'id': 83261011, + 'name': 'Tech Room', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 78, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=5aae72a6-1bd0-4d84-9bfd-673e7bc4907c', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059676', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/3412f1d96eb0c5edb5466c3c0598af60c06f8443f21e9bcb', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '02853E08', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261015', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261015&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261015, + 'name': 'Living West', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261018', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261018&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261018, + 'name': 'David Office', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059676?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059676?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059676, + 'indoor_humidity': '52', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Downstairs West Wing', + 'name_editable': True, + 'outdoor_temperature': '88', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/dehumidify', + }), + }), + 'current_value': 0.45, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261015', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261015&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261015, + 'name': 'Living West', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261018', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261018&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261018, + 'name': 'David Office', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e3fc90c7-2885-4f57-ae76-99e9ec81eef0', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2293892', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/967361e8aed874aa5230930fd0e0bbd8b653261e982a6e0e', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '0281B02C', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Cooling', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394133, + 'name': 'Bath Closet', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130', + }), + }), + 'cooling_setpoint': 71, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 71, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Open', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394130, + 'name': 'Master', + 'operating_state': 'Damper Open', + 'setpoints': dict({ + 'cool': 71, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Open', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394136, + 'name': 'Nick Office', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83394127, + 'name': 'Snooze Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394139, + 'name': 'Safe Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_on', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.69, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2293892?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2293892?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2293892, + 'indoor_humidity': '52', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Master Suite', + 'name_editable': True, + 'outdoor_temperature': '87', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/dehumidify', + }), + }), + 'current_value': 0.45, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'Cooling', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394133, + 'name': 'Bath Closet', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130', + }), + }), + 'cooling_setpoint': 71, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 71, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Open', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394130, + 'name': 'Master', + 'operating_state': 'Damper Open', + 'setpoints': dict({ + 'cool': 71, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Open', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394136, + 'name': 'Nick Office', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83394127, + 'name': 'Snooze Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394139, + 'name': 'Safe Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=3679e95b-7337-48ae-aff4-e0522e9dd0eb', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059652', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/c6627726f6339d104ee66897028d6a2ea38215675b336650', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '02853DF0', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991', + }), + }), + 'cooling_setpoint': 80, + 'current_zone_mode': 'OFF', + 'features': list([ + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Off', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'OFF', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260991', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260991&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83260991, + 'name': 'Hallway', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 80, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + }), + }), + 'current_value': 'OFF', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260994', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260994&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83260994, + 'name': 'Mid Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260997', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260997&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83260997, + 'name': 'West Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059652?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059652?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059652, + 'indoor_humidity': '37', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Upstairs West Wing', + 'name_editable': True, + 'outdoor_temperature': '87', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/dehumidify', + }), + }), + 'current_value': 0.5, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991', + }), + }), + 'cooling_setpoint': 80, + 'current_zone_mode': 'OFF', + 'features': list([ + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Off', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'OFF', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260991', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260991&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83260991, + 'name': 'Hallway', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 80, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + }), + }), + 'current_value': 'OFF', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260994', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260994&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83260994, + 'name': 'Mid Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260997', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260997&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83260997, + 'name': 'West Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + ]), + 'entry': dict({ + 'brand': None, + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/nexia/test_diagnostics.py b/tests/components/nexia/test_diagnostics.py index f58574098cc..9f8f7f05a8d 100644 --- a/tests/components/nexia/test_diagnostics.py +++ b/tests/components/nexia/test_diagnostics.py @@ -1,4 +1,6 @@ """Test august diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from .util import async_init_integration @@ -8,9109 +10,12 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" entry = await async_init_integration(hass) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag == { - "automations": [ - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3467876" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=472ae0d2-5d7c-4a1c-9e47-4d9035fdace5" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "?automation_id=3467876" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3467876" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "62.0 and cool to 83.0 AND Downstairs East " - "Wing will permanently hold the heat to 62.0 " - "and cool to 83.0 AND Downstairs West Wing " - "will permanently hold the heat to 62.0 and " - "cool to 83.0 AND Activate the mode named " - "'Away 12' AND Master Suite will permanently " - "hold the heat to 62.0 and cool to 83.0" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "plane"}, - {"modifiers": [], "name": "climate"}, - ], - "id": 3467876, - "name": "Away for 12 Hours", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3467870" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=f63ee20c-3146-49a1-87c5-47429a063d15" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3467870" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3467870" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "60.0 and cool to 85.0 AND Downstairs East " - "Wing will permanently hold the heat to 60.0 " - "and cool to 85.0 AND Downstairs West Wing " - "will permanently hold the heat to 60.0 and " - "cool to 85.0 AND Activate the mode named " - "'Away 24' AND Master Suite will permanently " - "hold the heat to 60.0 and cool to 85.0" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "plane"}, - {"modifiers": [], "name": "climate"}, - ], - "id": 3467870, - "name": "Away For 24 Hours", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3452469" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=e5c59b93-efca-4937-9499-3f4c896ab17c" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3452469" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3452469" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "63.0 and cool to 80.0 AND Downstairs East " - "Wing will permanently hold the heat to 63.0 " - "and cool to 79.0 AND Downstairs West Wing " - "will permanently hold the heat to 63.0 and " - "cool to 79.0 AND Upstairs West Wing will " - "permanently hold the heat to 63.0 and cool " - "to 81.0 AND Upstairs West Wing will change " - "Fan Mode to Auto AND Downstairs East Wing " - "will change Fan Mode to Auto AND Downstairs " - "West Wing will change Fan Mode to Auto AND " - "Activate the mode named 'Away Short' AND " - "Master Suite will permanently hold the heat " - "to 63.0 and cool to 79.0 AND Master Suite " - "will change Fan Mode to Auto" - ), - "enabled": False, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "key"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3452469, - "name": "Away Short", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3452472" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=861b9fec-d259-4492-a798-5712251666c4" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3452472" - ), - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3452472" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Activate the " - "mode named 'Home' AND Master Suite will Run " - "Schedule" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "at_home"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3452472, - "name": "Home", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3454776" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=96c71d37-66aa-4cbb-84ff-a90412fd366a" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3454776" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3454776" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "60.0 and cool to 85.0 AND Downstairs East " - "Wing will permanently hold the heat to 60.0 " - "and cool to 85.0 AND Downstairs West Wing " - "will permanently hold the heat to 60.0 and " - "cool to 85.0 AND Upstairs West Wing will " - "change Fan Mode to Auto AND Downstairs East " - "Wing will change Fan Mode to Auto AND " - "Downstairs West Wing will change Fan Mode to " - "Auto AND Master Suite will permanently hold " - "the heat to 60.0 and cool to 85.0 AND Master " - "Suite will change Fan Mode to Auto" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3454776, - "name": "IFTTT Power Spike", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3454774" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=880c5287-d92c-4368-8494-e10975e92733" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3454774" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3454774" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Master Suite " - "will Run Schedule" - ), - "enabled": False, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3454774, - "name": "IFTTT return to schedule", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3486078" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=d33c013b-2357-47a9-8c66-d2c3693173b0" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3486078" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3486078" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "55.0 and cool to 90.0 AND Downstairs East " - "Wing will permanently hold the heat to 55.0 " - "and cool to 90.0 AND Downstairs West Wing " - "will permanently hold the heat to 55.0 and " - "cool to 90.0 AND Activate the mode named " - "'Power Outage'" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "bell"}, - ], - "id": 3486078, - "name": "Power Outage", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3486091" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=b9141df8-2e5e-4524-b8ef-efcbf48d775a" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3486091" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3486091" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Activate the " - "mode named 'Home'" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "at_home"}, - ], - "id": 3486091, - "name": "Power Restored", - "settings": [], - "triggers": [], - }, - ], - "devices": [ - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=cd9a70e8-fd0d-4b58-b071-05a202fd8953" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059661" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/be6d8ede5cac02fe8be18c334b04d539c9200fa9230eef63" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - {"label": "AUID", "type": "label_value", "value": "000000"}, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261002/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile" - "/schedules" - "?device_identifier=XxlZone-83261002" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-71"], - "name": "thermostat", - }, - "id": 83261002, - "name": "Living East", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 71, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261005" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-77"], - "name": "thermostat", - }, - "id": 83261005, - "name": "Kitchen", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261008" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-72"], - "name": "thermostat", - }, - "id": 83261008, - "name": "Down Bedroom", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 78, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile" - "/schedules" - "?device_identifier" - "=XxlZone-83261011" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-78"], - "name": "thermostat", - }, - "id": 83261011, - "name": "Tech Room", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 78, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile" - "/runtime_history/2059661?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile" - "/runtime_history/2059661?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-71"], "name": "thermostat"}, - {"modifiers": ["temperature-77"], "name": "thermostat"}, - {"modifiers": ["temperature-72"], "name": "thermostat"}, - {"modifiers": ["temperature-78"], "name": "thermostat"}, - ], - "id": 2059661, - "indoor_humidity": "36", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Downstairs East Wing", - "name_editable": True, - "outdoor_temperature": "88", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661" - "/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/dehumidify" - ) - } - }, - "current_value": 0.5, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261002" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-71"], "name": "thermostat"}, - "id": 83261002, - "name": "Living East", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261002/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 71, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261005" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261005/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261005" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-77"], "name": "thermostat"}, - "id": 83261005, - "name": "Kitchen", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261008" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261008" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-72"], "name": "thermostat"}, - "id": 83261008, - "name": "Down Bedroom", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261011" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 78, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261011" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-78"], "name": "thermostat"}, - "id": 83261011, - "name": "Tech Room", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 78, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=5aae72a6-1bd0-4d84-9bfd-673e7bc4907c" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059676" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/3412f1d96eb0c5edb5466c3c0598af60c06f8443f21e9bcb" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "02853E08", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261015" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83261015, - "name": "Living West", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261018" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83261018, - "name": "David Office", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059676?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059676?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-75"], "name": "thermostat"}, - {"modifiers": ["temperature-75"], "name": "thermostat"}, - ], - "id": 2059676, - "indoor_humidity": "52", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Downstairs West Wing", - "name_editable": True, - "outdoor_temperature": "88", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/dehumidify" - ) - } - }, - "current_value": 0.45, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261015" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261015" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83261015, - "name": "Living West", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261018" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261018/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261018" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83261018, - "name": "David Office", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=e3fc90c7-2885-4f57-ae76-99e9ec81eef0" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2293892" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/967361e8aed874aa5230930fd0e0bbd8b653261e982a6e0e" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "0281B02C", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Cooling", - "status_icon": {"modifiers": [], "name": "cooling"}, - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394133" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-73"], - "name": "thermostat", - }, - "id": 83394133, - "name": "Bath Closet", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - ) - } - }, - "cooling_setpoint": 71, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 71, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Open", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394130" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83394130, - "name": "Master", - "operating_state": "Damper Open", - "setpoints": {"cool": 71, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Open", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394136" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-73"], - "name": "thermostat", - }, - "id": 83394136, - "name": "Nick Office", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394127" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-72"], - "name": "thermostat", - }, - "id": 83394127, - "name": "Snooze Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394139" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83394139, - "name": "Safe Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2293892/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_on"}, - "value": "auto", - }, - {"compressor_speed": 0.69, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2293892?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2293892?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-73"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - {"modifiers": ["temperature-73"], "name": "thermostat"}, - {"modifiers": ["temperature-72"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - ], - "id": 2293892, - "indoor_humidity": "52", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Master Suite", - "name_editable": True, - "outdoor_temperature": "87", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2293892/dehumidify" - ) - } - }, - "current_value": 0.45, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2293892/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "Cooling", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394133" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394133/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394133" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-73"], "name": "thermostat"}, - "id": 83394133, - "name": "Bath Closet", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394130" - ) - } - }, - "cooling_setpoint": 71, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 71, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Open", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394130" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83394130, - "name": "Master", - "operating_state": "Damper Open", - "setpoints": {"cool": 71, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Open", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394136" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394136" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-73"], "name": "thermostat"}, - "id": 83394136, - "name": "Nick Office", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394127" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394127/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394127" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-72"], "name": "thermostat"}, - "id": 83394127, - "name": "Snooze Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394139" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394139/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394139" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83394139, - "name": "Safe Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=3679e95b-7337-48ae-aff4-e0522e9dd0eb" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059652" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/c6627726f6339d104ee66897028d6a2ea38215675b336650" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "02853DF0", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - ) - } - }, - "cooling_setpoint": 80, - "current_zone_mode": "OFF", - "features": [ - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Off", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "OFF", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260991" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-77"], - "name": "thermostat", - }, - "id": 83260991, - "name": "Hallway", - "operating_state": "", - "setpoints": {"cool": 80, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ) - } - }, - "current_value": "OFF", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260994" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83260994, - "name": "Mid Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260997" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83260997, - "name": "West Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059652?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059652?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-77"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - {"modifiers": ["temperature-75"], "name": "thermostat"}, - ], - "id": 2059652, - "indoor_humidity": "37", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Upstairs West Wing", - "name_editable": True, - "outdoor_temperature": "87", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652" - "/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/dehumidify" - ) - } - }, - "current_value": 0.5, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260991" - ) - } - }, - "cooling_setpoint": 80, - "current_zone_mode": "OFF", - "features": [ - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Off", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "OFF", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260991" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-77"], "name": "thermostat"}, - "id": 83260991, - "name": "Hallway", - "operating_state": "", - "setpoints": {"cool": 80, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ) - } - }, - "current_value": "OFF", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260994" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260994" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83260994, - "name": "Mid Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260997" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260997" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83260997, - "name": "West Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - ], - "entry": {"brand": None, "title": "Mock Title"}, - } + assert diag == snapshot From ef6d77586a3ef50e007134e751e0f7683ee117e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 15:01:28 -0500 Subject: [PATCH 532/984] Bump python-amcrest to 1.9.8 (#100324) --- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 75d12a3271c..8b8d87092c4 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/amcrest", "iot_class": "local_polling", "loggers": ["amcrest"], - "requirements": ["amcrest==1.9.7"] + "requirements": ["amcrest==1.9.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58be5cdac44..86f8c52892a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -403,7 +403,7 @@ alpha-vantage==2.3.1 amberelectric==1.0.4 # homeassistant.components.amcrest -amcrest==1.9.7 +amcrest==1.9.8 # homeassistant.components.androidtv androidtv[async]==0.0.70 From 72f5c0741b655f46c83d0bb63d88a304f629a23e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 13 Sep 2023 22:29:16 +0200 Subject: [PATCH 533/984] Add missing sms coordinator to .coveragerc (#100327) --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 686e3eaaadd..305d02f2dbd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1139,6 +1139,7 @@ omit = homeassistant/components/smarty/* homeassistant/components/sms/__init__.py homeassistant/components/sms/const.py + homeassistant/components/sms/coordinator.py homeassistant/components/sms/gateway.py homeassistant/components/sms/notify.py homeassistant/components/sms/sensor.py From fe5eba9b31bb525f075edf046af8b3542db85e84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 15:36:07 -0500 Subject: [PATCH 534/984] Use cached_property in device registry (#100309) --- homeassistant/helpers/device_registry.py | 14 +++++--------- tests/syrupy.py | 1 - 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 64d102d020f..064579a95d3 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -12,6 +12,7 @@ from urllib.parse import urlparse import attr from yarl import URL +from homeassistant.backports.functools import cached_property from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -211,7 +212,7 @@ def _validate_configuration_url(value: Any) -> str | None: return str(value) -@attr.s(slots=True, frozen=True) +@attr.s(frozen=True) class DeviceEntry: """Device Registry Entry.""" @@ -234,8 +235,6 @@ class DeviceEntry: # This value is not stored, just used to keep track of events to fire. is_new: bool = attr.ib(default=False) - _json_repr: str | None = attr.ib(cmp=False, default=None, init=False, repr=False) - @property def disabled(self) -> bool: """Return if entry is disabled.""" @@ -262,15 +261,12 @@ class DeviceEntry: "via_device_id": self.via_device_id, } - @property + @cached_property def json_repr(self) -> str | None: """Return a cached JSON representation of the entry.""" - if self._json_repr is not None: - return self._json_repr - try: dict_repr = self.dict_repr - object.__setattr__(self, "_json_repr", JSON_DUMP(dict_repr)) + return JSON_DUMP(dict_repr) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", @@ -279,7 +275,7 @@ class DeviceEntry: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - return self._json_repr + return None @attr.s(slots=True, frozen=True) diff --git a/tests/syrupy.py b/tests/syrupy.py index 4846e013f5d..9209654a607 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -158,7 +158,6 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - serialized.pop("_json_repr") return serialized @classmethod From a02fcbc5c4ed974a5bf4d83bce168ca85e8fb288 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 13 Sep 2023 23:37:48 +0200 Subject: [PATCH 535/984] Update sentry-sdk to 1.31.0 (#100293) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 149e503d0f8..fa1044414bb 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.28.1"] + "requirements": ["sentry-sdk==1.31.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86f8c52892a..3c453903dac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.28.1 +sentry-sdk==1.31.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82e2d21b943..f13438bf85e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1754,7 +1754,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.28.1 +sentry-sdk==1.31.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From a7c6abfed1db20c2dcc48c2b61f76715a5ac3cf1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 00:11:27 +0200 Subject: [PATCH 536/984] Use shorthand atts for met_eireann (#100335) --- .../components/met_eireann/weather.py | 44 ++++++------------- 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 3a45a74c36b..7602dca8343 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -94,24 +94,20 @@ class MetEireannWeather( self._attr_unique_id = _calculate_unique_id(config, hourly) self._config = config self._hourly = hourly - - @property - def name(self): - """Return the name of the sensor.""" - name = self._config.get(CONF_NAME) - name_appendix = "" - if self._hourly: - name_appendix = " Hourly" - - if name is not None: - return f"{name}{name_appendix}" - - return f"{DEFAULT_NAME}{name_appendix}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return not self._hourly + name_appendix = " Hourly" if hourly else "" + if (name := self._config.get(CONF_NAME)) is not None: + self._attr_name = f"{name}{name_appendix}" + else: + self._attr_name = f"{DEFAULT_NAME}{name_appendix}" + self._attr_entity_registry_enabled_default = not hourly + self._attr_device_info = DeviceInfo( + name="Forecast", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, + manufacturer="Met Éireann", + model="Forecast", + configuration_url="https://www.met.ie", + ) @property def condition(self): @@ -191,15 +187,3 @@ class MetEireannWeather( def _async_forecast_hourly(self) -> list[Forecast]: """Return the hourly forecast in native units.""" return self._forecast(True) - - @property - def device_info(self): - """Device info.""" - return DeviceInfo( - name="Forecast", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, - manufacturer="Met Éireann", - model="Forecast", - configuration_url="https://www.met.ie", - ) From 01410c9fbb912ffdde00798ae337ccf040fbd056 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 00:11:47 +0200 Subject: [PATCH 537/984] Shorthanded attrs for met integration (#100334) --- homeassistant/components/met/weather.py | 74 +++++++++---------------- 1 file changed, 26 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a5a0d34d4eb..a1cc1ade8e1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import MappingProxyType -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -51,11 +51,16 @@ async def async_setup_entry( coordinator: MetDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entity_registry = er.async_get(hass) - entities = [ - MetWeather( - coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, False - ) - ] + name: str | None + is_metric = hass.config.units is METRIC_SYSTEM + if config_entry.data.get(CONF_TRACK_HOME, False): + name = hass.config.location_name + elif (name := config_entry.data.get(CONF_NAME)) and name is None: + name = DEFAULT_NAME + elif TYPE_CHECKING: + assert isinstance(name, str) + + entities = [MetWeather(coordinator, config_entry.data, False, name, is_metric)] # Add hourly entity to legacy config entries if entity_registry.async_get_entity_id( @@ -63,10 +68,9 @@ async def async_setup_entry( DOMAIN, _calculate_unique_id(config_entry.data, True), ): + name = f"{name} hourly" entities.append( - MetWeather( - coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, True - ) + MetWeather(coordinator, config_entry.data, True, name, is_metric) ) async_add_entities(entities) @@ -111,8 +115,9 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): self, coordinator: MetDataUpdateCoordinator, config: MappingProxyType[str, Any], - is_metric: bool, hourly: bool, + name: str, + is_metric: bool, ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) @@ -120,32 +125,17 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): self._config = config self._is_metric = is_metric self._hourly = hourly - - @property - def track_home(self) -> Any | bool: - """Return if we are tracking home.""" - return self._config.get(CONF_TRACK_HOME, False) - - @property - def name(self) -> str: - """Return the name of the sensor.""" - name = self._config.get(CONF_NAME) - name_appendix = "" - if self._hourly: - name_appendix = " hourly" - - if name is not None: - return f"{name}{name_appendix}" - - if self.track_home: - return f"{self.hass.config.location_name}{name_appendix}" - - return f"{DEFAULT_NAME}{name_appendix}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return not self._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] + manufacturer="Met.no", + model="Forecast", + configuration_url="https://www.met.no/en", + ) + self._attr_track_home = self._config.get(CONF_TRACK_HOME, False) + self._attr_name = name @property def condition(self) -> str | None: @@ -248,15 +238,3 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" return self._forecast(True) - - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - return DeviceInfo( - name="Forecast", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, # type: ignore[arg-type] - manufacturer="Met.no", - model="Forecast", - configuration_url="https://www.met.no/en", - ) From f0e607869a7dc527da044fe5784a681b05f6addc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:25:33 -0500 Subject: [PATCH 538/984] Use shorthand attributes for supla cover device class (#100337) from #95315 --- homeassistant/components/supla/cover.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 53e57fe1854..cc3a5a4ed0c 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -95,6 +95,8 @@ class SuplaCoverEntity(SuplaEntity, CoverEntity): class SuplaDoorEntity(SuplaEntity, CoverEntity): """Representation of a Supla door.""" + _attr_device_class = CoverDeviceClass.GARAGE + @property def is_closed(self) -> bool | None: """Return if the door is closed or not.""" @@ -120,8 +122,3 @@ class SuplaDoorEntity(SuplaEntity, CoverEntity): async def async_toggle(self, **kwargs: Any) -> None: """Toggle the door.""" await self.async_action("OPEN_CLOSE") - - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of this device, from component DEVICE_CLASSES.""" - return CoverDeviceClass.GARAGE From fe8156f01344eb50a7983b35234b6228af8c2cbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:25:52 -0500 Subject: [PATCH 539/984] Bump protobuf to 4.24.3 (#100329) changelog: https://github.com/protocolbuffers/protobuf/compare/v24.0...v24.3 --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cf815b43b91..21616a68c32 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -148,7 +148,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.0 +protobuf==4.24.3 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7d587d761ec..c2bbfd4ffe3 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -149,7 +149,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.0 +protobuf==4.24.3 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From 3be4edd647fcf0a21aeccc2faca85c22b7dc2f41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:26:22 -0500 Subject: [PATCH 540/984] Use shorthand attributes in saj (#100317) supports #95315 --- homeassistant/components/saj/sensor.py | 52 +++++++++++--------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 12a5ae99570..866279af973 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -181,7 +181,12 @@ class SAJsensor(SensorEntity): _attr_should_poll = False - def __init__(self, serialnumber, pysaj_sensor, inverter_name=None): + def __init__( + self, + serialnumber: str | None, + pysaj_sensor: pysaj.Sensor, + inverter_name: str | None = None, + ) -> None: """Initialize the SAJ sensor.""" self._sensor = pysaj_sensor self._inverter_name = inverter_name @@ -193,38 +198,28 @@ class SAJsensor(SensorEntity): if pysaj_sensor.name == "total_yield": self._attr_state_class = SensorStateClass.TOTAL_INCREASING - @property - def name(self) -> str: - """Return the name of the sensor.""" + self._attr_unique_id = f"{serialnumber}_{pysaj_sensor.name}" + native_uom = SAJ_UNIT_MAPPINGS[pysaj_sensor.unit] + self._attr_native_unit_of_measurement = native_uom if self._inverter_name: - return f"saj_{self._inverter_name}_{self._sensor.name}" - - return f"saj_{self._sensor.name}" + self._attr_name = f"saj_{self._inverter_name}_{pysaj_sensor.name}" + else: + self._attr_name = f"saj_{pysaj_sensor.name}" + if native_uom == UnitOfPower.WATT: + self._attr_device_class = SensorDeviceClass.POWER + if native_uom == UnitOfEnergy.KILO_WATT_HOUR: + self._attr_device_class = SensorDeviceClass.ENERGY + if native_uom in ( + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ): + self._attr_device_class = SensorDeviceClass.TEMPERATURE @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return SAJ_UNIT_MAPPINGS[self._sensor.unit] - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the device class the sensor belongs to.""" - if self.native_unit_of_measurement == UnitOfPower.WATT: - return SensorDeviceClass.POWER - if self.native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR: - return SensorDeviceClass.ENERGY - if self.native_unit_of_measurement in ( - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, - ): - return SensorDeviceClass.TEMPERATURE - return None - @property def per_day_basis(self) -> bool: """Return if the sensors value is on daily basis or not.""" @@ -255,8 +250,3 @@ class SAJsensor(SensorEntity): if update: self.async_write_ha_state() - - @property - def unique_id(self) -> str: - """Return a unique identifier for this sensor.""" - return f"{self._serialnumber}_{self._sensor.name}" From 3cc9410a62f2b7eb914a6556dfc548b86c19f203 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:26:55 -0500 Subject: [PATCH 541/984] Bump grpcio to 1.58.0 (#100314) * Bump grpcio to 1.58.0 attempt to fix nightly https://github.com/home-assistant/core/actions/runs/6167125867/job/16737677629 ``` ``` * forgot the script as well --- homeassistant/package_constraints.txt | 6 +++--- script/gen_requirements_all.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 21616a68c32..2f26e5a6c33 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -71,9 +71,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.51.1 -grpcio-status==1.51.1 -grpcio-reflection==1.51.1 +grpcio==1.58.0 +grpcio-status==1.58.0 +grpcio-reflection==1.58.0 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c2bbfd4ffe3..8780b9d0743 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -72,9 +72,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.51.1 -grpcio-status==1.51.1 -grpcio-reflection==1.51.1 +grpcio==1.58.0 +grpcio-status==1.58.0 +grpcio-reflection==1.58.0 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, From 547f32818c8cfe13bbc69a5779b288e6b93048b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:33:25 -0500 Subject: [PATCH 542/984] Make core States use cached_property (#100312) Need to validate this is worth removing __slots__ --- homeassistant/components/api/__init__.py | 6 ++-- .../components/websocket_api/commands.py | 8 ++--- .../components/websocket_api/messages.py | 2 +- homeassistant/core.py | 30 ++++--------------- homeassistant/helpers/template.py | 2 -- tests/test_core.py | 20 ++++++------- 6 files changed, 24 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a1a2d1107b9..0cade0f81ca 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -202,11 +202,11 @@ class APIStatesView(HomeAssistantView): user: User = request["hass_user"] hass: HomeAssistant = request.app["hass"] if user.is_admin: - states = (state.as_dict_json() for state in hass.states.async_all()) + states = (state.as_dict_json for state in hass.states.async_all()) else: entity_perm = user.permissions.check_entity states = ( - state.as_dict_json() + state.as_dict_json for state in hass.states.async_all() if entity_perm(state.entity_id, "read") ) @@ -233,7 +233,7 @@ class APIEntityStateView(HomeAssistantView): if state := hass.states.get(entity_id): return web.Response( - body=state.as_dict_json(), + body=state.as_dict_json, content_type=CONTENT_TYPE_JSON, ) return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index bd7d3b530cd..cef9e7bb706 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -270,7 +270,7 @@ def handle_get_states( states = _async_get_allowed_states(hass, connection) try: - serialized_states = [state.as_dict_json() for state in states] + serialized_states = [state.as_dict_json for state in states] except (ValueError, TypeError): pass else: @@ -281,7 +281,7 @@ def handle_get_states( serialized_states = [] for state in states: try: - serialized_states.append(state.as_dict_json()) + serialized_states.append(state.as_dict_json) except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", @@ -358,7 +358,7 @@ def handle_subscribe_entities( # to succeed for the UI to show. try: serialized_states = [ - state.as_compressed_state_json() + state.as_compressed_state_json for state in states if not entity_ids or state.entity_id in entity_ids ] @@ -371,7 +371,7 @@ def handle_subscribe_entities( serialized_states = [] for state in states: try: - serialized_states.append(state.as_compressed_state_json()) + serialized_states.append(state.as_compressed_state_json) except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 1114eec4fac..6e88c36c328 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -141,7 +141,7 @@ def _state_diff_event(event: Event) -> dict: if (event_old_state := event.data["old_state"]) is None: return { ENTITY_EVENT_ADD: { - event_new_state.entity_id: event_new_state.as_compressed_state() + event_new_state.entity_id: event_new_state.as_compressed_state } } if TYPE_CHECKING: diff --git a/homeassistant/core.py b/homeassistant/core.py index cbfc8097c7f..a43fa1997c6 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -35,6 +35,7 @@ import voluptuous as vol import yarl from . import block_async_io, util +from .backports.functools import cached_property from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -1239,20 +1240,6 @@ class State: object_id: Object id of this state. """ - __slots__ = ( - "entity_id", - "state", - "attributes", - "last_changed", - "last_updated", - "context", - "domain", - "object_id", - "_as_dict", - "_as_dict_json", - "_as_compressed_state_json", - ) - def __init__( self, entity_id: str, @@ -1282,8 +1269,6 @@ class State: self.context = context or Context() self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None - self._as_dict_json: str | None = None - self._as_compressed_state_json: str | None = None @property def name(self) -> str: @@ -1318,12 +1303,12 @@ class State: ) return self._as_dict + @cached_property def as_dict_json(self) -> str: """Return a JSON string of the State.""" - if not self._as_dict_json: - self._as_dict_json = json_dumps(self.as_dict()) - return self._as_dict_json + return json_dumps(self.as_dict()) + @cached_property def as_compressed_state(self) -> dict[str, Any]: """Build a compressed dict of a state for adds. @@ -1348,6 +1333,7 @@ class State: ) return compressed_state + @cached_property def as_compressed_state_json(self) -> str: """Build a compressed JSON key value pair of a state for adds. @@ -1355,11 +1341,7 @@ class State: It is used for sending multiple states in a single message. """ - if not self._as_compressed_state_json: - self._as_compressed_state_json = json_dumps( - {self.entity_id: self.as_compressed_state()} - )[1:-1] - return self._as_compressed_state_json + return json_dumps({self.entity_id: self.as_compressed_state})[1:-1] @classmethod def from_dict(cls, json_dict: dict[str, Any]) -> Self | None: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 070e5b6d9ad..b0754c13c7c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -929,8 +929,6 @@ class DomainStates: class TemplateStateBase(State): """Class to represent a state object in a template.""" - __slots__ = ("_hass", "_collect", "_entity_id", "__dict__") - _state: State __setitem__ = _readonly diff --git a/tests/test_core.py b/tests/test_core.py index c5ce9eb0881..7cafadb638c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -671,11 +671,11 @@ def test_state_as_dict_json() -> None: '"last_changed":"1984-12-08T12:00:00","last_updated":"1984-12-08T12:00:00",' '"context":{"id":"01H0D6K3RFJAYAV2093ZW30PCW","parent_id":null,"user_id":null}}' ) - as_dict_json_1 = state.as_dict_json() + as_dict_json_1 = state.as_dict_json assert as_dict_json_1 == expected # 2nd time to verify cache - assert state.as_dict_json() == expected - assert state.as_dict_json() is as_dict_json_1 + assert state.as_dict_json == expected + assert state.as_dict_json is as_dict_json_1 def test_state_as_compressed_state() -> None: @@ -694,12 +694,12 @@ def test_state_as_compressed_state() -> None: "lc": last_time.timestamp(), "s": "on", } - as_compressed_state = state.as_compressed_state() + as_compressed_state = state.as_compressed_state # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state() == expected + assert state.as_compressed_state == expected def test_state_as_compressed_state_unique_last_updated() -> None: @@ -720,12 +720,12 @@ def test_state_as_compressed_state_unique_last_updated() -> None: "lu": last_updated.timestamp(), "s": "on", } - as_compressed_state = state.as_compressed_state() + as_compressed_state = state.as_compressed_state # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state() == expected + assert state.as_compressed_state == expected def test_state_as_compressed_state_json() -> None: @@ -740,13 +740,13 @@ def test_state_as_compressed_state_json() -> None: context=ha.Context(id="01H0D6H5K3SZJ3XGDHED1TJ79N"), ) expected = '"happy.happy":{"s":"on","a":{"pig":"dog"},"c":"01H0D6H5K3SZJ3XGDHED1TJ79N","lc":471355200.0}' - as_compressed_state = state.as_compressed_state_json() + as_compressed_state = state.as_compressed_state_json # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state_json() == expected - assert state.as_compressed_state_json() is as_compressed_state + assert state.as_compressed_state_json == expected + assert state.as_compressed_state_json is as_compressed_state async def test_eventbus_add_remove_listener(hass: HomeAssistant) -> None: From c265d3f3ccf464c32154219369453974f13b5cd3 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 14 Sep 2023 00:22:28 -0400 Subject: [PATCH 543/984] Late review for honeywell (#100299) * Late review for honeywell * Actually test same id different domain * Update homeassistant/components/honeywell/climate.py Co-authored-by: Martin Hjelmare * Update climate.py * Refactor dont_remove --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/honeywell/climate.py | 11 ++++++++--- tests/components/honeywell/test_init.py | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index c285ab83bd1..63d05135d5d 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -111,18 +111,23 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids: list = [] + all_device_ids: set = set() for device in devices.values(): - all_device_ids.append(device.deviceid) + all_device_ids.add(device.deviceid) for device_entry in device_entries: device_id: str | None = None + remove = True for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN: + remove = False + continue + device_id = identifier[1] break - if device_id is None or device_id not in all_device_ids: + if remove and (device_id is None or device_id not in all_device_ids): # If device_id is None an invalid device entry was found for this config entry. # If the device_id is not in existing device ids it's a stale device entry. # Remove config entry from this device entry in either case. diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index e5afe311295..73dda8ed223 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -130,7 +130,15 @@ async def test_remove_stale_device( ) -> None: """Test that the stale device is removed.""" location.devices_by_id[another_device.deviceid] = another_device + config_entry.add_to_hass(hass) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("OtherDomain", 7654321)}, + ) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED @@ -142,9 +150,12 @@ async def test_remove_stale_device( device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 2 + assert len(device_entry) == 3 assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) assert any((DOMAIN, 7654321) in device.identifiers for device in device_entry) + assert any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ) assert await config_entry.async_unload(hass) await hass.async_block_till_done() @@ -162,5 +173,8 @@ async def test_remove_stale_device( device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 1 + assert len(device_entry) == 2 assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) + assert any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ) From 58bb624b24d8a5ad030400155059f0a262a7f2a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 00:54:17 -0500 Subject: [PATCH 544/984] Bump zeroconf to 0.111.0 (#100340) --- 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 1e2205a1c1b..34cf72f180d 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.108.0"] + "requirements": ["zeroconf==0.111.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2f26e5a6c33..bd8984afe00 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.108.0 +zeroconf==0.111.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 3c453903dac..6cd211a7ca1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.108.0 +zeroconf==0.111.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f13438bf85e..0cb9ca0c215 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2045,7 +2045,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.108.0 +zeroconf==0.111.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 182976f5d3000e14a205f9fb0ec4d2d36cf498ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 02:03:39 -0500 Subject: [PATCH 545/984] Use more shorthand attributes in threshold binary_sensor (#100343) --- .../components/threshold/binary_sensor.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3e702f0ebdb..6382c79b9ce 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -183,14 +183,14 @@ class ThresholdSensor(BinarySensorEntity): self._attr_unique_id = unique_id self._attr_device_info = device_info self._entity_id = entity_id - self._name = name + self._attr_name = name if lower is not None: self._threshold_lower = lower if upper is not None: self._threshold_upper = upper self.threshold_type = _threshold_type(lower, upper) self._hysteresis: float = hysteresis - self._device_class = device_class + self._attr_device_class = device_class self._state_position = POSITION_UNKNOWN self._state: bool | None = None self.sensor_value: float | None = None @@ -227,21 +227,11 @@ class ThresholdSensor(BinarySensorEntity): ) _update_sensor_state() - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - @property def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the sensor class of the sensor.""" - return self._device_class - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" From 6692a37f0d2314f36edbfb93b2409d3648445647 Mon Sep 17 00:00:00 2001 From: Marty Sun Date: Thu, 14 Sep 2023 15:04:12 +0800 Subject: [PATCH 546/984] Add missing __init__.py file in yardian test folder (#100345) --- CODEOWNERS | 1 + requirements_test_all.txt | 3 +++ tests/components/yardian/__init__.py | 1 + 3 files changed, 5 insertions(+) create mode 100644 tests/components/yardian/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 3aefaabb50b..7463731e57a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1458,6 +1458,7 @@ build.json @home-assistant/supervisor /homeassistant/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis /homeassistant/components/yardian/ @h3l1o5 +/tests/components/yardian/ @h3l1o5 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0cb9ca0c215..02e2be74082 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1666,6 +1666,9 @@ pywizlight==0.5.14 # homeassistant.components.ws66i pyws66i==1.1 +# homeassistant.components.yardian +pyyardian==1.1.0 + # homeassistant.components.zerproc pyzerproc==0.4.8 diff --git a/tests/components/yardian/__init__.py b/tests/components/yardian/__init__.py new file mode 100644 index 00000000000..47f8cbc509e --- /dev/null +++ b/tests/components/yardian/__init__.py @@ -0,0 +1 @@ +"""Tests for the yardian integration.""" From 923d9452674f58c9a54b95c8a23f8f05bf702754 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 09:25:21 +0200 Subject: [PATCH 547/984] Use shorthand attributes in Smappee (#99837) --- .../components/smappee/binary_sensor.py | 136 ++++++------------ homeassistant/components/smappee/sensor.py | 18 +-- homeassistant/components/smappee/switch.py | 22 ++- 3 files changed, 61 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py index 71bbaa472ae..ed09b51ff25 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -15,6 +15,23 @@ from .const import DOMAIN BINARY_SENSOR_PREFIX = "Appliance" PRESENCE_PREFIX = "Presence" +ICON_MAPPING = { + "Car Charger": "mdi:car", + "Coffeemaker": "mdi:coffee", + "Clothes Dryer": "mdi:tumble-dryer", + "Clothes Iron": "mdi:hanger", + "Dishwasher": "mdi:dishwasher", + "Lights": "mdi:lightbulb", + "Fan": "mdi:fan", + "Freezer": "mdi:fridge", + "Microwave": "mdi:microwave", + "Oven": "mdi:stove", + "Refrigerator": "mdi:fridge", + "Stove": "mdi:stove", + "Washing Machine": "mdi:washing-machine", + "Water Pump": "mdi:water-pump", +} + async def async_setup_entry( hass: HomeAssistant, @@ -48,54 +65,33 @@ async def async_setup_entry( class SmappeePresence(BinarySensorEntity): """Implementation of a Smappee presence binary sensor.""" + _attr_device_class = BinarySensorDeviceClass.PRESENCE + def __init__(self, smappee_base, service_location): """Initialize the Smappee sensor.""" self._smappee_base = smappee_base self._service_location = service_location - self._state = self._service_location.is_present - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"{self._service_location.service_location_name} - {PRESENCE_PREFIX}" - - @property - def is_on(self): - """Return if the binary sensor is turned on.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return BinarySensorDeviceClass.PRESENCE - - @property - def unique_id( - self, - ): - """Return the unique ID for this binary sensor.""" - return ( - f"{self._service_location.device_serial_number}-" - f"{self._service_location.service_location_id}-" + self._attr_name = ( + f"{service_location.service_location_name} - {PRESENCE_PREFIX}" + ) + self._attr_unique_id = ( + f"{service_location.device_serial_number}-" + f"{service_location.service_location_id}-" f"{BinarySensorDeviceClass.PRESENCE}" ) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this binary sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, ) async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() - self._state = self._service_location.is_present + self._attr_is_on = self._service_location.is_present class SmappeeAppliance(BinarySensorEntity): @@ -113,70 +109,28 @@ class SmappeeAppliance(BinarySensorEntity): self._smappee_base = smappee_base self._service_location = service_location self._appliance_id = appliance_id - self._appliance_name = appliance_name - self._appliance_type = appliance_type - self._state = False - - @property - def name(self): - """Return the name of the sensor.""" - return ( - f"{self._service_location.service_location_name} - " + self._attr_name = ( + f"{service_location.service_location_name} - " f"{BINARY_SENSOR_PREFIX} - " - f"{self._appliance_name if self._appliance_name != '' else self._appliance_type}" + f"{appliance_name if appliance_name != '' else appliance_type}" ) - - @property - def is_on(self): - """Return if the binary sensor is turned on.""" - return self._state - - @property - def icon(self): - """Icon to use in the frontend.""" - icon_mapping = { - "Car Charger": "mdi:car", - "Coffeemaker": "mdi:coffee", - "Clothes Dryer": "mdi:tumble-dryer", - "Clothes Iron": "mdi:hanger", - "Dishwasher": "mdi:dishwasher", - "Lights": "mdi:lightbulb", - "Fan": "mdi:fan", - "Freezer": "mdi:fridge", - "Microwave": "mdi:microwave", - "Oven": "mdi:stove", - "Refrigerator": "mdi:fridge", - "Stove": "mdi:stove", - "Washing Machine": "mdi:washing-machine", - "Water Pump": "mdi:water-pump", - } - return icon_mapping.get(self._appliance_type) - - @property - def unique_id( - self, - ): - """Return the unique ID for this binary sensor.""" - return ( - f"{self._service_location.device_serial_number}-" - f"{self._service_location.service_location_id}-" - f"appliance-{self._appliance_id}" + self._attr_unique_id = ( + f"{service_location.device_serial_number}-" + f"{service_location.service_location_id}-" + f"appliance-{appliance_id}" ) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this binary sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, ) + self._attr_icon = ICON_MAPPING.get(appliance_type) async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() appliance = self._service_location.appliances.get(self._appliance_id) - self._state = bool(appliance.state) + self._attr_is_on = bool(appliance.state) diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 4228f57ea46..82bc60936b3 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -341,6 +341,13 @@ class SmappeeSensor(SensorEntity): self.entity_description = description self._smappee_base = smappee_base self._service_location = service_location + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, + manufacturer="Smappee", + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, + ) @property def name(self): @@ -372,17 +379,6 @@ class SmappeeSensor(SensorEntity): f"{sensor_key}" ) - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, - manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, - ) - async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 1928e717f22..238e41af8ff 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -74,10 +74,17 @@ class SmappeeActuator(SwitchEntity): self._actuator_type = actuator_type self._actuator_serialnumber = actuator_serialnumber self._actuator_state_option = actuator_state_option - self._state = self._service_location.actuators.get(actuator_id).state - self._connection_state = self._service_location.actuators.get( + self._state = service_location.actuators.get(actuator_id).state + self._connection_state = service_location.actuators.get( actuator_id ).connection_state + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, + manufacturer="Smappee", + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, + ) @property def name(self): @@ -153,17 +160,6 @@ class SmappeeActuator(SwitchEntity): f"{self._actuator_id}" ) - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this switch.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, - manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, - ) - async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() From 840d881c25de33f5d5750cb613a81b6101ae205a Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Thu, 14 Sep 2023 09:27:16 +0200 Subject: [PATCH 548/984] Add icon to GPSD (#100347) --- homeassistant/components/gpsd/sensor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 3e356f1509c..64b86434c3c 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -121,3 +121,12 @@ class GpsdSensor(SensorEntity): ATTR_CLIMB: self.agps_thread.data_stream.climb, ATTR_MODE: self.agps_thread.data_stream.mode, } + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + mode = self.agps_thread.data_stream.mode + + if isinstance(mode, int) and mode >= 2: + return "mdi:crosshairs-gps" + return "mdi:crosshairs" From 4a48a92cba5cb3fb6e13eabc6014f85f5407bc92 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 10:06:43 +0200 Subject: [PATCH 549/984] Use f-string instead of concatenation in Velux (#100353) --- homeassistant/components/velux/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index ef552573115..b43ee39ed4e 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -94,7 +94,7 @@ class VeluxEntity(Entity): """Initialize the Velux device.""" self.node = node self._attr_unique_id = node.serial_number - self._attr_name = node.name if node.name else "#" + str(node.node_id) + self._attr_name = node.name if node.name else f"#{node.node_id}" @callback def async_register_callbacks(self): From 85fafcad1165b7a0c6b91c4afec10a24e8cfa440 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 14 Sep 2023 10:07:09 +0200 Subject: [PATCH 550/984] Update awesomeversion to 23.8.0 (#100349) --- 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 bd8984afe00..8beeae2f960 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ async-timeout==4.0.3 async-upnp-client==0.35.1 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 -awesomeversion==22.9.0 +awesomeversion==23.8.0 bcrypt==4.0.1 bleak-retry-connector==3.1.3 bleak==0.21.1 diff --git a/pyproject.toml b/pyproject.toml index 7bc3edc9bf0..bfc3472651c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "async-timeout==4.0.3", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", - "awesomeversion==22.9.0", + "awesomeversion==23.8.0", "bcrypt==4.0.1", "certifi>=2021.5.30", "ciso8601==2.3.0", diff --git a/requirements.txt b/requirements.txt index 28e853f4fe1..2f6024a2e6a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ astral==2.2 async-timeout==4.0.3 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 -awesomeversion==22.9.0 +awesomeversion==23.8.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 From 4f58df1929551dc9516863f3123fc4f241c5ff26 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 14 Sep 2023 10:09:55 +0200 Subject: [PATCH 551/984] Drop useless passing of update_method to DataUpdateCoordinator (#100355) --- homeassistant/components/goodwe/coordinator.py | 1 - homeassistant/components/rainbird/coordinator.py | 1 - homeassistant/components/vizio/__init__.py | 1 - homeassistant/components/yardian/coordinator.py | 1 - 4 files changed, 4 deletions(-) diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py index 0ae064e0e97..ac91fba787d 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -30,7 +30,6 @@ class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): _LOGGER, name=entry.title, update_interval=SCAN_INTERVAL, - update_method=self._async_update_data, ) self.inverter: Inverter = inverter self._last_data: dict[str, Any] = {} diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index d81b942d669..cac86d8c928 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -54,7 +54,6 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): hass, _LOGGER, name=name, - update_method=self._async_update_data, update_interval=UPDATE_INTERVAL, ) self._controller = controller diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index d694f4b93f8..0f5b3bc967c 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -107,7 +107,6 @@ class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]] _LOGGER, name=DOMAIN, update_interval=timedelta(days=1), - update_method=self._async_update_data, ) self.fail_count = 0 self.fail_threshold = 10 diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index 526ee3c42ab..e7102f9c74b 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -39,7 +39,6 @@ class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): hass, _LOGGER, name=entry.title, - update_method=self._async_update_data, update_interval=SCAN_INTERVAL, always_update=False, ) From b0f32a35479c312ed09274917d5b1212d83e8858 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 14 Sep 2023 10:10:31 +0200 Subject: [PATCH 552/984] Update apprise to 1.5.0 (#100351) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 04dcef05202..e67192040a6 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.4.5"] + "requirements": ["apprise==1.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6cd211a7ca1..c0c3c38e930 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -427,7 +427,7 @@ apcaccess==0.0.13 apple_weatherkit==1.0.2 # homeassistant.components.apprise -apprise==1.4.5 +apprise==1.5.0 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02e2be74082..ab581701d11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -393,7 +393,7 @@ apcaccess==0.0.13 apple_weatherkit==1.0.2 # homeassistant.components.apprise -apprise==1.4.5 +apprise==1.5.0 # homeassistant.components.aprs aprslib==0.7.0 From b84076d3d6393f64ca092160aca6a42c2142ae8f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 14 Sep 2023 10:28:45 +0200 Subject: [PATCH 553/984] Fix timeout issue in devolo_home_network (#100350) --- homeassistant/components/devolo_home_network/__init__.py | 2 +- homeassistant/components/devolo_home_network/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index f54fddc9a86..627a121dcb4 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -74,7 +74,7 @@ async def async_setup_entry( # noqa: C901 """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): + async with asyncio.timeout(30): return await device.device.async_check_firmware_available() except DeviceUnavailable as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index a047437e980..27fd08898c0 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["devolo_plc_api"], "quality_scale": "platinum", - "requirements": ["devolo-plc-api==1.4.0"], + "requirements": ["devolo-plc-api==1.4.1"], "zeroconf": [ { "type": "_dvl-deviceapi._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index c0c3c38e930..133d43bc2f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -675,7 +675,7 @@ denonavr==0.11.3 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.4.0 +devolo-plc-api==1.4.1 # homeassistant.components.directv directv==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab581701d11..9b2912119aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -552,7 +552,7 @@ denonavr==0.11.3 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.4.0 +devolo-plc-api==1.4.1 # homeassistant.components.directv directv==0.4.0 From 98c9edc00c985a9328e5f9596462be839a5644ac Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 Sep 2023 12:06:40 +0200 Subject: [PATCH 554/984] Netgear cleanup (#99505) Co-authored-by: Robert Resch Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + homeassistant/components/netgear/__init__.py | 19 +-- homeassistant/components/netgear/button.py | 7 +- .../components/netgear/device_tracker.py | 8 +- homeassistant/components/netgear/entity.py | 107 +++++++++++++ homeassistant/components/netgear/router.py | 143 +----------------- homeassistant/components/netgear/sensor.py | 13 +- homeassistant/components/netgear/switch.py | 10 +- homeassistant/components/netgear/update.py | 6 +- 9 files changed, 132 insertions(+), 182 deletions(-) create mode 100644 homeassistant/components/netgear/entity.py diff --git a/.coveragerc b/.coveragerc index 305d02f2dbd..4835ec5a05b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -793,6 +793,7 @@ omit = homeassistant/components/netgear/__init__.py homeassistant/components/netgear/button.py homeassistant/components/netgear/device_tracker.py + homeassistant/components/netgear/entity.py homeassistant/components/netgear/router.py homeassistant/components/netgear/sensor.py homeassistant/components/netgear/switch.py diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 522b60749d0..b21286ff05b 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -6,7 +6,7 @@ import logging from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.const import CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -62,23 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) - configuration_url = None - if host := entry.data[CONF_HOST]: - configuration_url = f"http://{host}/" - - assert entry.unique_id - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.unique_id)}, - manufacturer="Netgear", - name=router.device_name, - model=router.model, - sw_version=router.firmware_version, - hw_version=router.hardware_version, - configuration_url=configuration_url, - ) - async def async_update_devices() -> bool: """Fetch data from the router.""" if router.track_devices: diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index e45e0582d69..f3283f8d7b5 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -15,7 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearRouterCoordinatorEntity +from .router import NetgearRouter @dataclass @@ -35,7 +36,6 @@ class NetgearButtonEntityDescription( BUTTONS = [ NetgearButtonEntityDescription( key="reboot", - name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, action=lambda router: router.async_reboot, @@ -69,8 +69,7 @@ class NetgearRouterButtonEntity(NetgearRouterCoordinatorEntity, ButtonEntity): """Initialize a Netgear device.""" super().__init__(coordinator, router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}" async def async_press(self) -> None: """Triggers the button press service.""" diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index ffb33d5ebeb..38ad024a2c4 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -10,7 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DEVICE_ICONS, DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearBaseEntity, NetgearRouter +from .entity import NetgearDeviceEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -46,9 +47,11 @@ async def async_setup_entry( new_device_callback() -class NetgearScannerEntity(NetgearBaseEntity, ScannerEntity): +class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): """Representation of a device connected to a Netgear router.""" + _attr_has_entity_name = False + def __init__( self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict ) -> None: @@ -56,6 +59,7 @@ class NetgearScannerEntity(NetgearBaseEntity, ScannerEntity): super().__init__(coordinator, router, device) self._hostname = self.get_hostname() self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network") + self._attr_name = self._device_name def get_hostname(self) -> str | None: """Return the hostname of the given device or None if we don't know.""" diff --git a/homeassistant/components/netgear/entity.py b/homeassistant/components/netgear/entity.py new file mode 100644 index 00000000000..45418681db0 --- /dev/null +++ b/homeassistant/components/netgear/entity.py @@ -0,0 +1,107 @@ +"""Represent the Netgear router and its devices.""" +from __future__ import annotations + +from abc import abstractmethod + +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .router import NetgearRouter + + +class NetgearDeviceEntity(CoordinatorEntity): + """Base class for a device connected to a Netgear router.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict + ) -> None: + """Initialize a Netgear device.""" + super().__init__(coordinator) + self._router = router + self._device = device + self._mac = device["mac"] + self._device_name = self.get_device_name() + self._active = device["active"] + self._attr_unique_id = self._mac + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, + default_name=self._device_name, + default_model=device["device_model"], + via_device=(DOMAIN, router.unique_id), + ) + + def get_device_name(self): + """Return the name of the given device or the MAC if we don't know.""" + name = self._device["name"] + if not name or name == "--": + name = self._mac + + return name + + @abstractmethod + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_update_device() + super()._handle_coordinator_update() + + +class NetgearRouterEntity(Entity): + """Base class for a Netgear router entity without coordinator.""" + + _attr_has_entity_name = True + + def __init__(self, router: NetgearRouter) -> None: + """Initialize a Netgear device.""" + self._router = router + + configuration_url = None + if host := router.entry.data[CONF_HOST]: + configuration_url = f"http://{host}/" + + self._attr_unique_id = router.serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, router.unique_id)}, + manufacturer="Netgear", + name=router.device_name, + model=router.model, + sw_version=router.firmware_version, + hw_version=router.hardware_version, + configuration_url=configuration_url, + ) + + +class NetgearRouterCoordinatorEntity(NetgearRouterEntity, CoordinatorEntity): + """Base class for a Netgear router entity.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter + ) -> None: + """Initialize a Netgear device.""" + CoordinatorEntity.__init__(self, coordinator) + NetgearRouterEntity.__init__(self, router) + + @abstractmethod + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_update_device() + super()._handle_coordinator_update() diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 2dc86833003..3c3be7fe9fb 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -1,7 +1,6 @@ """Represent the Netgear router and its devices.""" from __future__ import annotations -from abc import abstractmethod import asyncio from datetime import timedelta import logging @@ -17,14 +16,8 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from homeassistant.util import dt as dt_util from .const import ( @@ -275,137 +268,3 @@ class NetgearRouter: def ssl(self) -> bool: """SSL used by the API.""" return self.api.ssl - - -class NetgearBaseEntity(CoordinatorEntity): - """Base class for a device connected to a Netgear router.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator) - self._router = router - self._device = device - self._mac = device["mac"] - self._name = self.get_device_name() - self._device_name = self._name - self._active = device["active"] - - def get_device_name(self): - """Return the name of the given device or the MAC if we don't know.""" - name = self._device["name"] - if not name or name == "--": - name = self._mac - - return name - - @abstractmethod - @callback - def async_update_device(self) -> None: - """Update the Netgear device.""" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.async_update_device() - super()._handle_coordinator_update() - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - -class NetgearDeviceEntity(NetgearBaseEntity): - """Base class for a device connected to a Netgear router.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator, router, device) - self._unique_id = self._mac - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, - default_name=self._device_name, - default_model=self._device["device_model"], - via_device=(DOMAIN, self._router.unique_id), - ) - - -class NetgearRouterCoordinatorEntity(CoordinatorEntity): - """Base class for a Netgear router entity.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator) - self._router = router - self._name = router.device_name - self._unique_id = router.serial_number - - @abstractmethod - @callback - def async_update_device(self) -> None: - """Update the Netgear device.""" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.async_update_device() - super()._handle_coordinator_update() - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._router.unique_id)}, - ) - - -class NetgearRouterEntity(Entity): - """Base class for a Netgear router entity without coordinator.""" - - def __init__(self, router: NetgearRouter) -> None: - """Initialize a Netgear device.""" - self._router = router - self._name = router.device_name - self._unique_id = router.serial_number - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._router.unique_id)}, - ) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 239eca5ff83..0de98515a87 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -36,7 +36,8 @@ from .const import ( KEY_COORDINATOR_UTIL, KEY_ROUTER, ) -from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearDeviceEntity, NetgearRouterCoordinatorEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -379,10 +380,9 @@ class NetgearSensorEntity(NetgearDeviceEntity, SensorEntity): """Initialize a Netgear device.""" super().__init__(coordinator, router, device) self._attribute = attribute - self.entity_description = SENSOR_TYPES[self._attribute] - self._name = f"{self.get_device_name()} {self.entity_description.name}" - self._unique_id = f"{self._mac}-{self._attribute}" - self._state = self._device.get(self._attribute) + self.entity_description = SENSOR_TYPES[attribute] + self._attr_unique_id = f"{self._mac}-{attribute}" + self._state = device.get(attribute) @property def native_value(self): @@ -413,8 +413,7 @@ class NetgearRouterSensorEntity(NetgearRouterCoordinatorEntity, RestoreSensor): """Initialize a Netgear device.""" super().__init__(coordinator, router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}-{entity_description.index}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}-{entity_description.index}" self._value: StateType | date | datetime | Decimal = None self.async_update_device() diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index 88a89dd32c9..f594506cbfb 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -15,7 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterEntity +from .entity import NetgearDeviceEntity, NetgearRouterEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -166,9 +167,7 @@ class NetgearAllowBlock(NetgearDeviceEntity, SwitchEntity): """Initialize a Netgear device.""" super().__init__(coordinator, router, device) self.entity_description = entity_description - self._name = f"{self.get_device_name()} {self.entity_description.name}" - self._unique_id = f"{self._mac}-{self.entity_description.key}" - self._attr_is_on = None + self._attr_unique_id = f"{self._mac}-{entity_description.key}" self.async_update_device() async def async_turn_on(self, **kwargs: Any) -> None: @@ -206,8 +205,7 @@ class NetgearRouterSwitchEntity(NetgearRouterEntity, SwitchEntity): """Initialize a Netgear device.""" super().__init__(router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}" self._attr_is_on = None self._attr_available = False diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index b0e9a26864b..78e11e7c174 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -15,7 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR_FIRMWARE, KEY_ROUTER -from .router import NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearRouterCoordinatorEntity +from .router import NetgearRouter LOGGER = logging.getLogger(__name__) @@ -44,8 +45,7 @@ class NetgearUpdateEntity(NetgearRouterCoordinatorEntity, UpdateEntity): ) -> None: """Initialize a Netgear device.""" super().__init__(coordinator, router) - self._name = f"{router.device_name} Update" - self._unique_id = f"{router.serial_number}-update" + self._attr_unique_id = f"{router.serial_number}-update" @property def installed_version(self) -> str | None: From f305661dd7761fcdea7028180a265f2c7a865395 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Thu, 14 Sep 2023 12:13:19 +0200 Subject: [PATCH 555/984] Change service `set_location` to use number input selectors (#100360) --- .../components/homeassistant/services.yaml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 09a280133f2..2b5fd3fc686 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -7,17 +7,27 @@ set_location: required: true example: 32.87336 selector: - text: + number: + mode: box + min: -90 + max: 90 + step: any longitude: required: true example: 117.22743 selector: - text: + number: + mode: box + min: -180 + max: 180 + step: any elevation: required: false example: 120 selector: - text: + number: + mode: box + step: any stop: toggle: From 8a8f1aff83ecaac120373c0cbc6cd5c41933f0a3 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 14 Sep 2023 12:35:21 +0200 Subject: [PATCH 556/984] Remove useless timeout guards in devolo_home_network (#100364) --- .../devolo_home_network/__init__.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 627a121dcb4..d76a6163516 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -1,7 +1,6 @@ """The devolo Home Network integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -74,8 +73,7 @@ async def async_setup_entry( # noqa: C901 """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(30): - return await device.device.async_check_firmware_available() + return await device.device.async_check_firmware_available() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -83,8 +81,7 @@ async def async_setup_entry( # noqa: C901 """Fetch data from API endpoint.""" assert device.plcnet try: - async with asyncio.timeout(10): - return await device.plcnet.async_get_network_overview() + return await device.plcnet.async_get_network_overview() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -92,8 +89,7 @@ async def async_setup_entry( # noqa: C901 """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_wifi_guest_access() + return await device.device.async_get_wifi_guest_access() except DeviceUnavailable as err: raise UpdateFailed(err) from err except DevicePasswordProtected as err: @@ -103,8 +99,7 @@ async def async_setup_entry( # noqa: C901 """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_led_setting() + return await device.device.async_get_led_setting() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -112,8 +107,7 @@ async def async_setup_entry( # noqa: C901 """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_wifi_connected_station() + return await device.device.async_get_wifi_connected_station() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -121,8 +115,7 @@ async def async_setup_entry( # noqa: C901 """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(30): - return await device.device.async_get_wifi_neighbor_access_points() + return await device.device.async_get_wifi_neighbor_access_points() except DeviceUnavailable as err: raise UpdateFailed(err) from err From b85865851636e84af82a2f0e69163902878ace36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 14 Sep 2023 12:51:06 +0200 Subject: [PATCH 557/984] Fix Airthings ble migration (#100362) * Import Platform for tests * Migration bugfix * Store new unique id as a variable in tests * Add comments to tests --- .../components/airthings_ble/sensor.py | 3 +- tests/components/airthings_ble/test_sensor.py | 45 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index b66d6b8f810..28b5fa3a7a6 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -144,7 +144,8 @@ def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: not matching_reg_entry or "(" not in entry.unique_id ): matching_reg_entry = entry - if not matching_reg_entry: + if not matching_reg_entry or matching_reg_entry.unique_id == new_unique_id: + # Already has the newest unique id format return entity_id = matching_reg_entry.entity_id ent_reg.async_update_entity(entity_id=entity_id, new_unique_id=new_unique_id) diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py index 68efd4d25f6..1bf036b735d 100644 --- a/tests/components/airthings_ble/test_sensor.py +++ b/tests/components/airthings_ble/test_sensor.py @@ -2,6 +2,7 @@ import logging from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.components.airthings_ble import ( @@ -31,11 +32,13 @@ async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): assert entry is not None assert device is not None + new_unique_id = f"{WAVE_DEVICE_INFO.address}_temperature" + entity_registry = hass.helpers.entity_registry.async_get(hass) sensor = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=TEMPERATURE_V1.unique_id, config_entry=entry, device_id=device.id, @@ -57,10 +60,7 @@ async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): assert len(hass.states.async_all()) > 0 - assert ( - entity_registry.async_get(sensor.entity_id).unique_id - == WAVE_DEVICE_INFO.address + "_temperature" - ) + assert entity_registry.async_get(sensor.entity_id).unique_id == new_unique_id async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): @@ -77,7 +77,7 @@ async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): sensor = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=HUMIDITY_V2.unique_id, config_entry=entry, device_id=device.id, @@ -99,10 +99,9 @@ async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): assert len(hass.states.async_all()) > 0 - assert ( - entity_registry.async_get(sensor.entity_id).unique_id - == WAVE_DEVICE_INFO.address + "_humidity" - ) + # Migration should happen, v2 unique id should be updated to the new format + new_unique_id = f"{WAVE_DEVICE_INFO.address}_humidity" + assert entity_registry.async_get(sensor.entity_id).unique_id == new_unique_id async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): @@ -119,7 +118,7 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): v2 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=CO2_V2.unique_id, config_entry=entry, device_id=device.id, @@ -127,7 +126,7 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): v1 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=CO2_V1.unique_id, config_entry=entry, device_id=device.id, @@ -149,11 +148,10 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): assert len(hass.states.async_all()) > 0 - assert ( - entity_registry.async_get(v1.entity_id).unique_id - == WAVE_DEVICE_INFO.address + "_co2" - ) - assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id + # Migration should happen, v1 unique id should be updated to the new format + new_unique_id = f"{WAVE_DEVICE_INFO.address}_co2" + assert entity_registry.async_get(v1.entity_id).unique_id == new_unique_id + assert entity_registry.async_get(v2.entity_id).unique_id == CO2_V2.unique_id async def test_migration_with_all_unique_ids(hass: HomeAssistant): @@ -170,7 +168,7 @@ async def test_migration_with_all_unique_ids(hass: HomeAssistant): v1 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=VOC_V1.unique_id, config_entry=entry, device_id=device.id, @@ -178,7 +176,7 @@ async def test_migration_with_all_unique_ids(hass: HomeAssistant): v2 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=VOC_V2.unique_id, config_entry=entry, device_id=device.id, @@ -186,7 +184,7 @@ async def test_migration_with_all_unique_ids(hass: HomeAssistant): v3 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=VOC_V3.unique_id, config_entry=entry, device_id=device.id, @@ -208,6 +206,7 @@ async def test_migration_with_all_unique_ids(hass: HomeAssistant): assert len(hass.states.async_all()) > 0 - assert entity_registry.async_get(v1.entity_id).unique_id == v1.unique_id - assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id - assert entity_registry.async_get(v3.entity_id).unique_id == v3.unique_id + # No migration should happen, unique id should be the same as before + assert entity_registry.async_get(v1.entity_id).unique_id == VOC_V1.unique_id + assert entity_registry.async_get(v2.entity_id).unique_id == VOC_V2.unique_id + assert entity_registry.async_get(v3.entity_id).unique_id == VOC_V3.unique_id From 6fc14076132beb3973bdea4bbc56005b5374fec4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 13:31:54 +0200 Subject: [PATCH 558/984] Extract Withings API specifics in own class (#100363) * Extract Withings API specifics in own class * Extract Withings API specifics in own class * Ignore api test coverage * fix feedback --- .coveragerc | 1 + homeassistant/components/withings/api.py | 167 ++++++++++++++++++++ homeassistant/components/withings/common.py | 147 ++++------------- tests/components/withings/conftest.py | 8 +- tests/components/withings/test_init.py | 16 +- 5 files changed, 214 insertions(+), 125 deletions(-) create mode 100644 homeassistant/components/withings/api.py diff --git a/.coveragerc b/.coveragerc index 4835ec5a05b..3c7ade54b0e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1481,6 +1481,7 @@ omit = homeassistant/components/wiffi/sensor.py homeassistant/components/wiffi/wiffi_strings.py homeassistant/components/wirelesstag/* + homeassistant/components/withings/api.py homeassistant/components/wolflink/__init__.py homeassistant/components/wolflink/sensor.py homeassistant/components/worldtidesinfo/sensor.py diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py new file mode 100644 index 00000000000..fff9767ebda --- /dev/null +++ b/homeassistant/components/withings/api.py @@ -0,0 +1,167 @@ +"""Api for Withings.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +import logging +from typing import Any + +import arrow +import requests +from withings_api import AbstractWithingsApi, DateType +from withings_api.common import ( + GetSleepSummaryField, + MeasureGetMeasGroupCategory, + MeasureGetMeasResponse, + MeasureType, + NotifyAppli, + NotifyListResponse, + SleepGetSummaryResponse, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2Implementation, + OAuth2Session, +) + +from .const import LOG_NAMESPACE + +_LOGGER = logging.getLogger(LOG_NAMESPACE) +_RETRY_COEFFICIENT = 0.5 + + +class ConfigEntryWithingsApi(AbstractWithingsApi): + """Withing API that uses HA resources.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + implementation: AbstractOAuth2Implementation, + ) -> None: + """Initialize object.""" + self._hass = hass + self.config_entry = config_entry + self._implementation = implementation + self.session = OAuth2Session(hass, config_entry, implementation) + + def _request( + self, path: str, params: dict[str, Any], method: str = "GET" + ) -> dict[str, Any]: + """Perform an async request.""" + asyncio.run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self._hass.loop + ).result() + + access_token = self.config_entry.data["token"]["access_token"] + response = requests.request( + method, + f"{self.URL}/{path}", + params=params, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10, + ) + return response.json() + + async def _do_retry(self, func, attempts=3) -> Any: + """Retry a function call. + + Withings' API occasionally and incorrectly throws errors. + Retrying the call tends to work. + """ + exception = None + for attempt in range(1, attempts + 1): + _LOGGER.debug("Attempt %s of %s", attempt, attempts) + try: + return await func() + except Exception as exception1: # pylint: disable=broad-except + _LOGGER.debug( + "Failed attempt %s of %s (%s)", attempt, attempts, exception1 + ) + # Make each backoff pause a little bit longer + await asyncio.sleep(_RETRY_COEFFICIENT * attempt) + exception = exception1 + continue + + if exception: + raise exception + + async def async_measure_get_meas( + self, + meastype: MeasureType | None = None, + category: MeasureGetMeasGroupCategory | None = None, + startdate: DateType | None = arrow.utcnow(), + enddate: DateType | None = arrow.utcnow(), + offset: int | None = None, + lastupdate: DateType | None = arrow.utcnow(), + ) -> MeasureGetMeasResponse: + """Get measurements.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.measure_get_meas, + meastype, + category, + startdate, + enddate, + offset, + lastupdate, + ) + ) + + async def async_sleep_get_summary( + self, + data_fields: Iterable[GetSleepSummaryField], + startdateymd: DateType | None = arrow.utcnow(), + enddateymd: DateType | None = arrow.utcnow(), + offset: int | None = None, + lastupdate: DateType | None = arrow.utcnow(), + ) -> SleepGetSummaryResponse: + """Get sleep data.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.sleep_get_summary, + data_fields, + startdateymd, + enddateymd, + offset, + lastupdate, + ) + ) + + async def async_notify_list( + self, appli: NotifyAppli | None = None + ) -> NotifyListResponse: + """List webhooks.""" + + return await self._do_retry( + await self._hass.async_add_executor_job(self.notify_list, appli) + ) + + async def async_notify_subscribe( + self, + callbackurl: str, + appli: NotifyAppli | None = None, + comment: str | None = None, + ) -> None: + """Subscribe to webhook.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.notify_subscribe, callbackurl, appli, comment + ) + ) + + async def async_notify_revoke( + self, callbackurl: str | None = None, appli: NotifyAppli | None = None + ) -> None: + """Revoke webhook.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.notify_revoke, callbackurl, appli + ) + ) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 98c98f1fa96..446fb4b58e5 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -13,8 +13,6 @@ import re from typing import Any from aiohttp.web import Response -import requests -from withings_api import AbstractWithingsApi from withings_api.common import ( AuthFailedException, GetSleepSummaryField, @@ -22,7 +20,6 @@ from withings_api.common import ( MeasureType, MeasureTypes, NotifyAppli, - SleepGetSummaryResponse, UnauthorizedException, query_measure_groups, ) @@ -33,18 +30,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - OAuth2Session, -) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import const +from .api import ConfigEntryWithingsApi from .const import Measurement _LOGGER = logging.getLogger(const.LOG_NAMESPACE) -_RETRY_COEFFICIENT = 0.5 NOT_AUTHENTICATED_ERROR = re.compile( f"^{HTTPStatus.UNAUTHORIZED},.*", re.IGNORECASE, @@ -114,40 +107,6 @@ WITHINGS_MEASURE_TYPE_MAP: dict[ } -class ConfigEntryWithingsApi(AbstractWithingsApi): - """Withing API that uses HA resources.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: AbstractOAuth2Implementation, - ) -> None: - """Initialize object.""" - self._hass = hass - self.config_entry = config_entry - self._implementation = implementation - self.session = OAuth2Session(hass, config_entry, implementation) - - def _request( - self, path: str, params: dict[str, Any], method: str = "GET" - ) -> dict[str, Any]: - """Perform an async request.""" - asyncio.run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self._hass.loop - ).result() - - access_token = self.config_entry.data["token"]["access_token"] - response = requests.request( - method, - f"{self.URL}/{path}", - params=params, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, - ) - return response.json() - - def json_message_response(message: str, message_code: int) -> Response: """Produce common json output.""" return HomeAssistantView.json({"message": message, "code": message_code}) @@ -271,34 +230,8 @@ class DataManager: self._cancel_subscription_update() self._cancel_subscription_update = None - async def _do_retry(self, func, attempts=3) -> Any: - """Retry a function call. - - Withings' API occasionally and incorrectly throws errors. - Retrying the call tends to work. - """ - exception = None - for attempt in range(1, attempts + 1): - _LOGGER.debug("Attempt %s of %s", attempt, attempts) - try: - return await func() - except Exception as exception1: # pylint: disable=broad-except - _LOGGER.debug( - "Failed attempt %s of %s (%s)", attempt, attempts, exception1 - ) - # Make each backoff pause a little bit longer - await asyncio.sleep(_RETRY_COEFFICIENT * attempt) - exception = exception1 - continue - - if exception: - raise exception - async def async_subscribe_webhook(self) -> None: """Subscribe the webhook to withings data updates.""" - return await self._do_retry(self._async_subscribe_webhook) - - async def _async_subscribe_webhook(self) -> None: _LOGGER.debug("Configuring withings webhook") # On first startup, perform a fresh re-subscribe. Withings stops pushing data @@ -311,7 +244,7 @@ class DataManager: self._subscribe_webhook_run_count += 1 # Get the current webhooks. - response = await self._hass.async_add_executor_job(self._api.notify_list) + response = await self._api.async_notify_list() subscribed_applis = frozenset( profile.appli @@ -338,17 +271,12 @@ class DataManager: # Withings will HTTP HEAD the callback_url and needs some downtime # between each call or there is a higher chance of failure. await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._hass.async_add_executor_job( - self._api.notify_subscribe, self._webhook_config.url, appli - ) + await self._api.async_notify_subscribe(self._webhook_config.url, appli) async def async_unsubscribe_webhook(self) -> None: """Unsubscribe webhook from withings data updates.""" - return await self._do_retry(self._async_unsubscribe_webhook) - - async def _async_unsubscribe_webhook(self) -> None: # Get the current webhooks. - response = await self._hass.async_add_executor_job(self._api.notify_list) + response = await self._api.async_notify_list() # Revoke subscriptions. for profile in response.profiles: @@ -361,14 +289,15 @@ class DataManager: # Quick calls to Withings can result in the service returning errors. # Give them some time to cool down. await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._hass.async_add_executor_job( - self._api.notify_revoke, profile.callbackurl, profile.appli - ) + await self._api.async_notify_revoke(profile.callbackurl, profile.appli) async def async_get_all_data(self) -> dict[MeasureType, Any] | None: """Update all withings data.""" try: - return await self._do_retry(self._async_get_all_data) + return { + **await self.async_get_measures(), + **await self.async_get_sleep_summary(), + } except Exception as exception: # User is not authenticated. if isinstance( @@ -379,21 +308,14 @@ class DataManager: raise exception - async def _async_get_all_data(self) -> dict[Measurement, Any] | None: - _LOGGER.info("Updating all withings data") - return { - **await self.async_get_measures(), - **await self.async_get_sleep_summary(), - } - async def async_get_measures(self) -> dict[Measurement, Any]: """Get the measures data.""" _LOGGER.debug("Updating withings measures") now = dt_util.utcnow() startdate = now - datetime.timedelta(days=7) - response = await self._hass.async_add_executor_job( - self._api.measure_get_meas, None, None, startdate, now, None, startdate + response = await self._api.async_measure_get_meas( + None, None, startdate, now, None, startdate ) # Sort from oldest to newest. @@ -424,31 +346,28 @@ class DataManager: ) yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - def get_sleep_summary() -> SleepGetSummaryResponse: - return self._api.sleep_get_summary( - lastupdate=yesterday_noon_utc, - data_fields=[ - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - GetSleepSummaryField.DEEP_SLEEP_DURATION, - GetSleepSummaryField.DURATION_TO_SLEEP, - GetSleepSummaryField.DURATION_TO_WAKEUP, - GetSleepSummaryField.HR_AVERAGE, - GetSleepSummaryField.HR_MAX, - GetSleepSummaryField.HR_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION, - GetSleepSummaryField.REM_SLEEP_DURATION, - GetSleepSummaryField.RR_AVERAGE, - GetSleepSummaryField.RR_MAX, - GetSleepSummaryField.RR_MIN, - GetSleepSummaryField.SLEEP_SCORE, - GetSleepSummaryField.SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION, - ], - ) - - response = await self._hass.async_add_executor_job(get_sleep_summary) + response = await self._api.async_sleep_get_summary( + lastupdate=yesterday_noon_utc, + data_fields=[ + GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, + GetSleepSummaryField.DEEP_SLEEP_DURATION, + GetSleepSummaryField.DURATION_TO_SLEEP, + GetSleepSummaryField.DURATION_TO_WAKEUP, + GetSleepSummaryField.HR_AVERAGE, + GetSleepSummaryField.HR_MAX, + GetSleepSummaryField.HR_MIN, + GetSleepSummaryField.LIGHT_SLEEP_DURATION, + GetSleepSummaryField.REM_SLEEP_DURATION, + GetSleepSummaryField.RR_AVERAGE, + GetSleepSummaryField.RR_MAX, + GetSleepSummaryField.RR_MIN, + GetSleepSummaryField.SLEEP_SCORE, + GetSleepSummaryField.SNORING, + GetSleepSummaryField.SNORING_EPISODE_COUNT, + GetSleepSummaryField.WAKEUP_COUNT, + GetSleepSummaryField.WAKEUP_DURATION, + ], + ) # Set the default to empty lists. raw_values: dict[GetSleepSummaryField, list[int]] = { diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index a5e51c68c40..f1df0e3a65a 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -15,7 +15,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.withings.common import ConfigEntryWithingsApi +from homeassistant.components.withings.api import ConfigEntryWithingsApi from homeassistant.components.withings.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -112,13 +112,13 @@ def mock_withings(): mock.user_get_device.return_value = UserGetDeviceResponse( **load_json_object_fixture("withings/get_device.json") ) - mock.measure_get_meas.return_value = MeasureGetMeasResponse( + mock.async_measure_get_meas.return_value = MeasureGetMeasResponse( **load_json_object_fixture("withings/get_meas.json") ) - mock.sleep_get_summary.return_value = SleepGetSummaryResponse( + mock.async_sleep_get_summary.return_value = SleepGetSummaryResponse( **load_json_object_fixture("withings/get_sleep.json") ) - mock.notify_list.return_value = NotifyListResponse( + mock.async_notify_list.return_value = NotifyListResponse( **load_json_object_fixture("withings/notify_list.json") ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 4e7eb812f0a..15f0fff808d 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -117,17 +117,19 @@ async def test_data_manager_webhook_subscription( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() - assert withings.notify_subscribe.call_count == 4 + assert withings.async_notify_subscribe.call_count == 4 webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.CIRCULATORY) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) + withings.async_notify_subscribe.assert_any_call( + webhook_url, NotifyAppli.CIRCULATORY + ) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) - withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) - withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) + withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) + withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) @pytest.mark.parametrize( From 7ea2087c452e51c5ce290a03a473984ef7f023a3 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 Sep 2023 13:58:53 +0200 Subject: [PATCH 559/984] Add Netgear entity translations (#100367) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/netgear/sensor.py | 54 ++++----- homeassistant/components/netgear/strings.json | 111 ++++++++++++++++++ homeassistant/components/netgear/switch.py | 16 +-- 3 files changed, 146 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 0de98515a87..6e7771d44cb 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -44,33 +44,33 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { "type": SensorEntityDescription( key="type", - name="link type", + translation_key="link_type", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lan", ), "link_rate": SensorEntityDescription( key="link_rate", - name="link rate", + translation_key="link_rate", native_unit_of_measurement="Mbps", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:speedometer", ), "signal": SensorEntityDescription( key="signal", - name="signal strength", + translation_key="signal_strength", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:wifi", ), "ssid": SensorEntityDescription( key="ssid", - name="ssid", + translation_key="ssid", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:wifi-marker", ), "conn_ap_mac": SensorEntityDescription( key="conn_ap_mac", - name="access point mac", + translation_key="access_point_mac", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:router-network", ), @@ -88,7 +88,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_TRAFFIC_TYPES = [ NetgearSensorEntityDescription( key="NewTodayUpload", - name="Upload today", + translation_key="upload_today", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -96,7 +96,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewTodayDownload", - name="Download today", + translation_key="download_today", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -104,7 +104,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewYesterdayUpload", - name="Upload yesterday", + translation_key="upload_yesterday", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -112,7 +112,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewYesterdayDownload", - name="Download yesterday", + translation_key="download_yesterday", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -120,7 +120,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewWeekUpload", - name="Upload week", + translation_key="upload_week", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -130,7 +130,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewWeekUpload", - name="Upload week average", + translation_key="upload_week_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -140,7 +140,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewWeekDownload", - name="Download week", + translation_key="download_week", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -150,7 +150,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewWeekDownload", - name="Download week average", + translation_key="download_week_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -160,7 +160,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewMonthUpload", - name="Upload month", + translation_key="upload_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -170,7 +170,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewMonthUpload", - name="Upload month average", + translation_key="upload_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -180,7 +180,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewMonthDownload", - name="Download month", + translation_key="download_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -190,7 +190,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewMonthDownload", - name="Download month average", + translation_key="download_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -200,7 +200,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewLastMonthUpload", - name="Upload last month", + translation_key="upload_last_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -210,7 +210,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewLastMonthUpload", - name="Upload last month average", + translation_key="upload_last_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -220,7 +220,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewLastMonthDownload", - name="Download last month", + translation_key="download_last_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -230,7 +230,7 @@ SENSOR_TRAFFIC_TYPES = [ ), NetgearSensorEntityDescription( key="NewLastMonthDownload", - name="Download last month average", + translation_key="download_last_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -243,7 +243,7 @@ SENSOR_TRAFFIC_TYPES = [ SENSOR_SPEED_TYPES = [ NetgearSensorEntityDescription( key="NewOOKLAUplinkBandwidth", - name="Uplink Bandwidth", + translation_key="uplink_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -251,7 +251,7 @@ SENSOR_SPEED_TYPES = [ ), NetgearSensorEntityDescription( key="NewOOKLADownlinkBandwidth", - name="Downlink Bandwidth", + translation_key="downlink_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -259,7 +259,7 @@ SENSOR_SPEED_TYPES = [ ), NetgearSensorEntityDescription( key="AveragePing", - name="Average Ping", + translation_key="average_ping", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MILLISECONDS, icon="mdi:wan", @@ -269,7 +269,7 @@ SENSOR_SPEED_TYPES = [ SENSOR_UTILIZATION = [ NetgearSensorEntityDescription( key="NewCPUUtilization", - name="CPU Utilization", + translation_key="cpu_utilization", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, icon="mdi:cpu-64-bit", @@ -277,7 +277,7 @@ SENSOR_UTILIZATION = [ ), NetgearSensorEntityDescription( key="NewMemoryUtilization", - name="Memory Utilization", + translation_key="memory_utilization", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -288,7 +288,7 @@ SENSOR_UTILIZATION = [ SENSOR_LINK_TYPES = [ NetgearSensorEntityDescription( key="NewEthernetLinkStatus", - name="Ethernet Link Status", + translation_key="ethernet_link_status", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:ethernet", ), diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index a903535d5a8..6b4883b8ce3 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -29,5 +29,116 @@ } } } + }, + "entity": { + "sensor": { + "link_type": { + "name": "Link type" + }, + "link_rate": { + "name": "Link rate" + }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "ssid": { + "name": "SSID" + }, + "access_point_mac": { + "name": "Access point mac" + }, + "upload_today": { + "name": "Upload today" + }, + "download_today": { + "name": "Download today" + }, + "upload_yesterday": { + "name": "Upload yesterday" + }, + "download_yesterday": { + "name": "Download yesterday" + }, + "upload_week": { + "name": "Upload this week" + }, + "upload_week_average": { + "name": "Upload this week average" + }, + "download_week": { + "name": "Download this week" + }, + "download_week_average": { + "name": "Download this week average" + }, + "upload_month": { + "name": "Upload this month" + }, + "upload_month_average": { + "name": "Upload this month average" + }, + "download_month": { + "name": "Download this month" + }, + "download_month_average": { + "name": "Download this month average" + }, + "upload_last_month": { + "name": "Upload last month" + }, + "upload_last_month_average": { + "name": "Upload last month average" + }, + "download_last_month": { + "name": "Download last month" + }, + "download_last_month_average": { + "name": "Download last month average" + }, + "uplink_bandwidth": { + "name": "Uplink bandwidth" + }, + "downlink_bandwidth": { + "name": "Downlink bandwidth" + }, + "average_ping": { + "name": "Average ping" + }, + "cpu_utilization": { + "name": "CPU utilization" + }, + "memory_utilization": { + "name": "Memory utilization" + }, + "ethernet_link_status": { + "name": "Ethernet link status" + } + }, + "switch": { + "allowed_on_network": { + "name": "Allowed on network" + }, + "access_control": { + "name": "Access control" + }, + "traffic_meter": { + "name": "Traffic meter" + }, + "parental_control": { + "name": "Parental control" + }, + "quality_of_service": { + "name": "Quality of service" + }, + "2g_guest_wifi": { + "name": "2.4GHz guest Wi-Fi" + }, + "5g_guest_wifi": { + "name": "5GHz guest Wi-Fi" + }, + "smart_connect": { + "name": "Smart connect" + } + } } } diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index f594506cbfb..a4548da16a4 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -25,7 +25,7 @@ SCAN_INTERVAL = timedelta(seconds=300) SWITCH_TYPES = [ SwitchEntityDescription( key="allow_or_block", - name="Allowed on network", + translation_key="allowed_on_network", icon="mdi:block-helper", entity_category=EntityCategory.CONFIG, ) @@ -50,7 +50,7 @@ class NetgearSwitchEntityDescription( ROUTER_SWITCH_TYPES = [ NetgearSwitchEntityDescription( key="access_control", - name="Access Control", + translation_key="access_control", icon="mdi:block-helper", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_block_device_enable_status, @@ -58,7 +58,7 @@ ROUTER_SWITCH_TYPES = [ ), NetgearSwitchEntityDescription( key="traffic_meter", - name="Traffic Meter", + translation_key="traffic_meter", icon="mdi:wifi-arrow-up-down", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_traffic_meter_enabled, @@ -66,7 +66,7 @@ ROUTER_SWITCH_TYPES = [ ), NetgearSwitchEntityDescription( key="parental_control", - name="Parental Control", + translation_key="parental_control", icon="mdi:account-child-outline", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_parental_control_enable_status, @@ -74,7 +74,7 @@ ROUTER_SWITCH_TYPES = [ ), NetgearSwitchEntityDescription( key="qos", - name="Quality of Service", + translation_key="quality_of_service", icon="mdi:wifi-star", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_qos_enable_status, @@ -82,7 +82,7 @@ ROUTER_SWITCH_TYPES = [ ), NetgearSwitchEntityDescription( key="2g_guest_wifi", - name="2.4G Guest Wifi", + translation_key="2g_guest_wifi", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_2g_guest_access_enabled, @@ -90,7 +90,7 @@ ROUTER_SWITCH_TYPES = [ ), NetgearSwitchEntityDescription( key="5g_guest_wifi", - name="5G Guest Wifi", + translation_key="5g_guest_wifi", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_5g_guest_access_enabled, @@ -98,7 +98,7 @@ ROUTER_SWITCH_TYPES = [ ), NetgearSwitchEntityDescription( key="smart_connect", - name="Smart Connect", + translation_key="smart_connect", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_smart_connect_enabled, From d4a2927ebe28c1309250058f0c194ff69419b220 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 14 Sep 2023 16:03:32 +0200 Subject: [PATCH 560/984] Solve racing problem in modbus test (#100287) * Test racing problem. * review comment. * Revert to approved state. This reverts commit 983d9d68e8f77bae33ef4f8f1ac8c31cddfa6dca. --- tests/components/modbus/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5d419ed28d5..c2f3e639580 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -884,7 +884,7 @@ async def test_stop_restart( caplog.set_level(logging.INFO) entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id).state in (STATE_UNKNOWN, STATE_UNAVAILABLE) hass.states.async_set(entity_id, 17) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "17" From 89eec9990b2fdb152d52aa2bbc83e4a12ed0508d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 16:55:47 +0200 Subject: [PATCH 561/984] Use shorthand device_type attr for plaato sensors (#100385) --- .../components/plaato/binary_sensor.py | 19 ++++++++----------- homeassistant/components/plaato/sensor.py | 16 +++++----------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py index a8b7dc51c1e..e8fbaa5d6f1 100644 --- a/homeassistant/components/plaato/binary_sensor.py +++ b/homeassistant/components/plaato/binary_sensor.py @@ -39,20 +39,17 @@ async def async_setup_entry( class PlaatoBinarySensor(PlaatoEntity, BinarySensorEntity): """Representation of a Binary Sensor.""" + def __init__(self, data, sensor_type, coordinator=None) -> None: + """Initialize plaato binary sensor.""" + super().__init__(data, sensor_type, coordinator) + if sensor_type is PlaatoKeg.Pins.LEAK_DETECTION: + self._attr_device_class = BinarySensorDeviceClass.PROBLEM + elif sensor_type is PlaatoKeg.Pins.POURING: + self._attr_device_class = BinarySensorDeviceClass.OPENING + @property def is_on(self): """Return true if the binary sensor is on.""" if self._coordinator is not None: return self._coordinator.data.binary_sensors.get(self._sensor_type) return False - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device, from BinarySensorDeviceClass.""" - if self._coordinator is None: - return None - if self._sensor_type is PlaatoKeg.Pins.LEAK_DETECTION: - return BinarySensorDeviceClass.PROBLEM - if self._sensor_type is PlaatoKeg.Pins.POURING: - return BinarySensorDeviceClass.OPENING - return None diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index b43e18e52f6..f3d9a5c3e41 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -72,17 +72,11 @@ async def async_setup_entry( class PlaatoSensor(PlaatoEntity, SensorEntity): """Representation of a Plaato Sensor.""" - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from SensorDeviceClass.""" - if ( - self._coordinator is not None - and self._sensor_type == PlaatoKeg.Pins.TEMPERATURE - ): - return SensorDeviceClass.TEMPERATURE - if self._sensor_type == ATTR_TEMP: - return SensorDeviceClass.TEMPERATURE - return None + def __init__(self, data, sensor_type, coordinator=None) -> None: + """Initialize plaato sensor.""" + super().__init__(data, sensor_type, coordinator) + if sensor_type is PlaatoKeg.Pins.TEMPERATURE or sensor_type == ATTR_TEMP: + self._attr_device_class = SensorDeviceClass.TEMPERATURE @property def native_value(self): From 8b7061b6341c8025705a667509d7c2a5821689fd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 18:10:31 +0200 Subject: [PATCH 562/984] Short handed device class for overkiz cover (#100394) --- .../overkiz/cover_entities/vertical_cover.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/overkiz/cover_entities/vertical_cover.py b/homeassistant/components/overkiz/cover_entities/vertical_cover.py index 6e72dacf5c6..2bc6f73103f 100644 --- a/homeassistant/components/overkiz/cover_entities/vertical_cover.py +++ b/homeassistant/components/overkiz/cover_entities/vertical_cover.py @@ -45,6 +45,17 @@ OVERKIZ_DEVICE_TO_DEVICE_CLASS = { class VerticalCover(OverkizGenericCover): """Representation of an Overkiz vertical cover.""" + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Initialize vertical cover.""" + super().__init__(device_url, coordinator) + self._attr_device_class = ( + OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) + or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) + or CoverDeviceClass.BLIND + ) + @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" @@ -64,15 +75,6 @@ class VerticalCover(OverkizGenericCover): return supported_features - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the device.""" - return ( - OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) - or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) - or CoverDeviceClass.BLIND - ) - @property def current_cover_position(self) -> int | None: """Return current position of cover. From 6701a449bd77c999d6ed185fd96af49c806712a3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 18:17:23 +0200 Subject: [PATCH 563/984] Use shorthand attrs for tasmota (#100390) --- homeassistant/components/tasmota/mixins.py | 10 +--- homeassistant/components/tasmota/sensor.py | 69 +++++++--------------- 2 files changed, 23 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index e99106d09e8..21030b8c14b 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -38,6 +38,9 @@ class TasmotaEntity(Entity): """Initialize.""" self._tasmota_entity = tasmota_entity self._unique_id = tasmota_entity.unique_id + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, tasmota_entity.mac)} + ) async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -61,13 +64,6 @@ class TasmotaEntity(Entity): """(Re)Subscribe to topics.""" await self._tasmota_entity.subscribe_topics() - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._tasmota_entity.mac)} - ) - @property def name(self) -> str | None: """Return the name of the binary sensor.""" diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index e718c0fdcf4..29d3f5c8c8a 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -274,6 +274,26 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): **kwds, ) + class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( + self._tasmota_entity.quantity, {} + ) + self._attr_device_class = class_or_icon.get(DEVICE_CLASS) + self._attr_state_class = class_or_icon.get(STATE_CLASS) + if self._tasmota_entity.quantity in status_sensor.SENSORS: + self._attr_entity_category = EntityCategory.DIAGNOSTIC + # Hide fast changing status sensors + if self._tasmota_entity.quantity in ( + hc.SENSOR_STATUS_IP, + hc.SENSOR_STATUS_RSSI, + hc.SENSOR_STATUS_SIGNAL, + hc.SENSOR_STATUS_VERSION, + ): + self._attr_entity_registry_enabled_default = False + self._attr_icon = class_or_icon.get(ICON) + self._attr_native_unit_of_measurement = SENSOR_UNIT_MAP.get( + self._tasmota_entity.unit, self._tasmota_entity.unit + ) + async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" self._tasmota_entity.set_on_state_callback(self.sensor_state_updated) @@ -288,58 +308,9 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): self._state = state self.async_write_ha_state() - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the device class of the sensor.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(DEVICE_CLASS) - - @property - def state_class(self) -> str | None: - """Return the state class of the sensor.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(STATE_CLASS) - - @property - def entity_category(self) -> EntityCategory | None: - """Return the category of the entity, if any.""" - if self._tasmota_entity.quantity in status_sensor.SENSORS: - return EntityCategory.DIAGNOSTIC - return None - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - # Hide fast changing status sensors - if self._tasmota_entity.quantity in ( - hc.SENSOR_STATUS_IP, - hc.SENSOR_STATUS_RSSI, - hc.SENSOR_STATUS_SIGNAL, - hc.SENSOR_STATUS_VERSION, - ): - return False - return True - - @property - def icon(self) -> str | None: - """Return the icon.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(ICON) - @property def native_value(self) -> datetime | str | None: """Return the state of the entity.""" if self._state_timestamp and self.device_class == SensorDeviceClass.TIMESTAMP: return self._state_timestamp return self._state - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit this state is expressed in.""" - return SENSOR_UNIT_MAP.get(self._tasmota_entity.unit, self._tasmota_entity.unit) From 1d4b731603125bb1e5b505dc0a7d23a701179d5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 12:40:47 -0500 Subject: [PATCH 564/984] Bump zeroconf to 0.112.0 (#100386) --- 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 34cf72f180d..d81ed1dfaaa 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.111.0"] + "requirements": ["zeroconf==0.112.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8beeae2f960..0952b339788 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.111.0 +zeroconf==0.112.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 133d43bc2f0..2ebb45937b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.111.0 +zeroconf==0.112.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b2912119aa..33440afc6b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2048,7 +2048,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.111.0 +zeroconf==0.112.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From f90919912578eea4e721b9f5d865b79c73937cc7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 14 Sep 2023 20:13:46 +0200 Subject: [PATCH 565/984] Remove hard coded Icon from Unifi device scanner (#100401) --- homeassistant/components/unifi/device_tracker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 746e3b1fcf0..22a530e0369 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -179,7 +179,6 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", has_entity_name=True, - icon="mdi:ethernet", allowed_fn=lambda controller, obj_id: controller.option_track_devices, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, From 3f2a660dabededb44900dfd0f9c245dacab71373 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 Sep 2023 21:24:23 +0200 Subject: [PATCH 566/984] Bump reolink-aio to 0.7.10 (#100376) --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 060490c6e56..221a6b8b59d 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.9"] + "requirements": ["reolink-aio==0.7.10"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 95aa26a1ff5..15ba4baed45 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -223,6 +223,7 @@ "state": { "off": "[%key:common::state::off%]", "auto": "Auto", + "onatnight": "On at night", "schedule": "Schedule", "adaptive": "Adaptive", "autoadaptive": "Auto adaptive" diff --git a/requirements_all.txt b/requirements_all.txt index 2ebb45937b7..8ca4f1aabbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2298,7 +2298,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.9 +reolink-aio==0.7.10 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33440afc6b6..8d225e3d9ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1697,7 +1697,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.9 +reolink-aio==0.7.10 # homeassistant.components.rflink rflink==0.0.65 From a62f16b4cc7a20fe6162d8ad858aa08423466e19 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 21:41:34 +0200 Subject: [PATCH 567/984] Remove obsolete strings from Withings (#100396) --- homeassistant/components/withings/strings.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 5fa155a1c1c..22718b305ec 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -1,18 +1,12 @@ { "config": { - "flow_title": "{profile}", "step": { - "profile": { - "title": "User Profile.", - "description": "Provide a unique profile name for this data. Typically this is the name of the profile you selected in the previous step.", - "data": { "profile": "Profile Name" } - }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The \"{profile}\" profile needs to be re-authenticated in order to continue receiving Withings data." + "description": "The Withings integration needs to re-authenticate your account" } }, "error": { From 157647dc440c8f3081836760adec4c33b18c60a3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 14 Sep 2023 21:52:21 +0200 Subject: [PATCH 568/984] Move solarlog coordinator to own file (#100402) --- .coveragerc | 1 + homeassistant/components/solarlog/__init__.py | 55 +----------------- .../components/solarlog/coordinator.py | 56 +++++++++++++++++++ 3 files changed, 59 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/solarlog/coordinator.py diff --git a/.coveragerc b/.coveragerc index 3c7ade54b0e..015d1c541e9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1157,6 +1157,7 @@ omit = homeassistant/components/solaredge_local/sensor.py homeassistant/components/solarlog/__init__.py homeassistant/components/solarlog/sensor.py + homeassistant/components/solarlog/coordinator.py homeassistant/components/solax/__init__.py homeassistant/components/solax/sensor.py homeassistant/components/soma/__init__.py diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index e0ab838922b..95cf5cc4567 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,19 +1,10 @@ """Solar-Log integration.""" -from datetime import timedelta -import logging -from urllib.parse import ParseResult, urlparse - -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SolarlogData PLATFORMS = [Platform.SENSOR] @@ -30,45 +21,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class SolarlogData(update_coordinator.DataUpdateCoordinator): - """Get and update the latest data.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the data object.""" - super().__init__( - hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) - ) - - host_entry = entry.data[CONF_HOST] - - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) - self.unique_id = entry.entry_id - self.name = entry.title - self.host = url.geturl() - - async def _async_update_data(self): - """Update the data from the SolarLog device.""" - try: - data = await self.hass.async_add_executor_job(SolarLog, self.host) - except (OSError, Timeout, HTTPError) as err: - raise update_coordinator.UpdateFailed(err) from err - - if data.time.year == 1999: - raise update_coordinator.UpdateFailed( - "Invalid data returned (can happen after Solarlog restart)." - ) - - self.logger.debug( - ( - "Connection to Solarlog successful. Retrieving latest Solarlog update" - " of %s" - ), - data.time, - ) - - return data diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py new file mode 100644 index 00000000000..d363256f355 --- /dev/null +++ b/homeassistant/components/solarlog/coordinator.py @@ -0,0 +1,56 @@ +"""DataUpdateCoordinator for solarlog integration.""" +from datetime import timedelta +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +_LOGGER = logging.getLogger(__name__) + + +class SolarlogData(update_coordinator.DataUpdateCoordinator): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + ) + + host_entry = entry.data[CONF_HOST] + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + self.unique_id = entry.entry_id + self.name = entry.title + self.host = url.geturl() + + async def _async_update_data(self): + """Update the data from the SolarLog device.""" + try: + data = await self.hass.async_add_executor_job(SolarLog, self.host) + except (OSError, Timeout, HTTPError) as err: + raise update_coordinator.UpdateFailed(err) from err + + if data.time.year == 1999: + raise update_coordinator.UpdateFailed( + "Invalid data returned (can happen after Solarlog restart)." + ) + + self.logger.debug( + ( + "Connection to Solarlog successful. Retrieving latest Solarlog update" + " of %s" + ), + data.time, + ) + + return data From c34c4f8f0393a9b5a1bd1c47df887a7d0f7c2c2a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 21:54:49 +0200 Subject: [PATCH 569/984] Reload on Withings options flow update (#100397) * Reload on Withings options flow update * Remove reload from reauth --- homeassistant/components/withings/__init__.py | 6 ++++++ homeassistant/components/withings/config_flow.py | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 841c9da3c70..589bfe79094 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -150,6 +150,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -171,6 +172,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_webhook_handler( hass: HomeAssistant, webhook_id: str, request: Request ) -> Response | None: diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index f25ef95210c..cce1c5ee23c 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -83,7 +83,6 @@ class WithingsFlowHandler( if self.reauth_entry.unique_id == user_id: self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) - await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") return self.async_abort(reason="wrong_account") From 23faa0882f023d18e7a658ce0711b2a782fa0852 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 22:10:28 +0200 Subject: [PATCH 570/984] Avoid multiline ternary use (#100381) --- homeassistant/components/iaqualink/sensor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index b18a85a43a5..15e8fc5836d 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -34,13 +34,13 @@ class HassAqualinkSensor(AqualinkEntity, SensorEntity): """Initialize AquaLink sensor.""" super().__init__(dev) self._attr_name = dev.label - if dev.name.endswith("_temp"): - self._attr_native_unit_of_measurement = ( - UnitOfTemperature.FAHRENHEIT - if dev.system.temp_unit == "F" - else UnitOfTemperature.CELSIUS - ) - self._attr_device_class = SensorDeviceClass.TEMPERATURE + if not dev.name.endswith("_temp"): + return + self._attr_device_class = SensorDeviceClass.TEMPERATURE + if dev.system.temp_unit == "F": + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + return + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @property def native_value(self) -> int | float | None: From df74ed0d40f04d4a5fb73ac4c9cdf0121bca7c3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 15:13:15 -0500 Subject: [PATCH 571/984] Bump bleak-retry-connector to 3.2.1 (#100377) --- 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 7908dbbad66..54f10fbc0c7 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.1.3", + "bleak-retry-connector==3.2.1", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0952b339788..98846c0a968 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.1.3 +bleak-retry-connector==3.2.1 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 8ca4f1aabbd..b9db7f55bdd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,7 @@ bimmer-connected==0.14.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.3 +bleak-retry-connector==3.2.1 # homeassistant.components.bluetooth bleak==0.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d225e3d9ad..9bee67b6747 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -443,7 +443,7 @@ bellows==0.36.3 bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.3 +bleak-retry-connector==3.2.1 # homeassistant.components.bluetooth bleak==0.21.1 From 5f20725fd5c87decb04fc0ccf1ec028b59841ae5 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 14 Sep 2023 22:32:50 +0200 Subject: [PATCH 572/984] Remove _next_refresh variable in update coordinator (#100323) * Remove _next_refresh variable * Adjust tomorrowio --- homeassistant/components/tomorrowio/__init__.py | 1 - homeassistant/helpers/update_coordinator.py | 12 ++++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 77675e3f2ec..626049276f5 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -221,7 +221,6 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self.async_refresh() self.update_interval = async_set_update_interval(self.hass, self._api) - self._next_refresh = None self._async_unsub_refresh() if self._listeners: self._schedule_refresh() diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 34651fcaf9d..2b570009a57 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -81,7 +81,6 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self._shutdown_requested = False self.config_entry = config_entries.current_entry.get() self.always_update = always_update - self._next_refresh: float | None = None # It's None before the first successful update. # Components should call async_config_entry_first_refresh @@ -184,7 +183,6 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): """Unschedule any pending refresh since there is no longer any listeners.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() - self._next_refresh = None def async_contexts(self) -> Generator[Any, None, None]: """Return all registered contexts.""" @@ -220,13 +218,13 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): # We use event.async_call_at because DataUpdateCoordinator does # not need an exact update interval. now = self.hass.loop.time() - if self._next_refresh is None or self._next_refresh <= now: - self._next_refresh = int(now) + self._microsecond - self._next_refresh += self.update_interval.total_seconds() + + next_refresh = int(now) + self._microsecond + next_refresh += self.update_interval.total_seconds() self._unsub_refresh = event.async_call_at( self.hass, self._job, - self._next_refresh, + next_refresh, ) async def _handle_refresh_interval(self, _now: datetime) -> None: @@ -265,7 +263,6 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): async def async_refresh(self) -> None: """Refresh data and log errors.""" - self._next_refresh = None await self._async_refresh(log_failures=True) async def _async_refresh( # noqa: C901 @@ -405,7 +402,6 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): """Manually update data, notify listeners and reset refresh interval.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() - self._next_refresh = None self.data = data self.last_update_success = True From 042776ebb82924d39ab706f9f3907967a2730eb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 17:48:48 -0500 Subject: [PATCH 573/984] Cache entity properties that are never expected to change in the base class (#95315) --- homeassistant/backports/functools.py | 12 +++---- .../components/abode/binary_sensor.py | 4 ++- .../components/binary_sensor/__init__.py | 3 +- homeassistant/components/button/__init__.py | 3 +- homeassistant/components/cover/__init__.py | 3 +- homeassistant/components/date/__init__.py | 3 +- homeassistant/components/datetime/__init__.py | 3 +- homeassistant/components/dsmr/sensor.py | 5 ++- homeassistant/components/event/__init__.py | 3 +- homeassistant/components/filter/sensor.py | 11 +++++-- .../components/group/binary_sensor.py | 3 +- homeassistant/components/group/sensor.py | 5 ++- .../components/here_travel_time/sensor.py | 5 ++- homeassistant/components/huawei_lte/sensor.py | 4 ++- .../components/humidifier/__init__.py | 3 +- .../components/image_processing/__init__.py | 3 +- .../components/integration/sensor.py | 12 +++++-- .../components/media_player/__init__.py | 3 +- .../components/mobile_app/binary_sensor.py | 2 +- homeassistant/components/mobile_app/entity.py | 4 ++- homeassistant/components/mobile_app/sensor.py | 2 +- homeassistant/components/number/__init__.py | 3 +- homeassistant/components/sensor/__init__.py | 3 +- homeassistant/components/statistics/sensor.py | 4 ++- homeassistant/components/switch/__init__.py | 3 +- homeassistant/components/template/weather.py | 4 ++- homeassistant/components/time/__init__.py | 3 +- .../components/unifiprotect/binary_sensor.py | 13 ++++++-- homeassistant/components/update/__init__.py | 3 +- homeassistant/components/zha/binary_sensor.py | 3 +- homeassistant/components/zwave_js/sensor.py | 13 ++++++-- homeassistant/helpers/entity.py | 6 ++-- tests/components/event/test_init.py | 2 ++ tests/components/update/test_init.py | 31 ++++++++++++++++--- tests/helpers/test_entity.py | 7 ++++- 35 files changed, 146 insertions(+), 48 deletions(-) diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index 212c8516b48..f031004685c 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -5,18 +5,18 @@ from collections.abc import Callable from types import GenericAlias from typing import Any, Generic, Self, TypeVar, overload -_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) -class cached_property(Generic[_T]): +class cached_property(Generic[_T_co]): # pylint: disable=invalid-name """Backport of Python 3.12's cached_property. Includes https://github.com/python/cpython/pull/101890/files """ - def __init__(self, func: Callable[[Any], _T]) -> None: + def __init__(self, func: Callable[[Any], _T_co]) -> None: """Initialize.""" - self.func: Callable[[Any], _T] = func + self.func: Callable[[Any], _T_co] = func self.attrname: str | None = None self.__doc__ = func.__doc__ @@ -35,12 +35,12 @@ class cached_property(Generic[_T]): ... @overload - def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T: + def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T_co: ... def __get__( self, instance: Any | None, owner: type[Any] | None = None - ) -> _T | Self: + ) -> _T_co | Self: """Get.""" if instance is None: return self diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index a10dbc8e664..43f0b8a289c 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -50,7 +50,9 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorEntity): """Return True if the binary sensor is on.""" return cast(bool, self._device.is_on) - @property + @property # type: ignore[override] + # We don't know if the class may be set late here + # so we need to override the property to disable the cache. def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of the binary sensor.""" if self._device.get_value("is_window") == "1": diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 79e20c6f571..f0b5d6e1d03 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -9,6 +9,7 @@ from typing import Literal, final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -197,7 +198,7 @@ class BinarySensorEntity(Entity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 901acdcdec1..735470033c9 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -9,6 +9,7 @@ from typing import final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -96,7 +97,7 @@ class ButtonEntity(RestoreEntity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 354b972e2b7..5fae199c961 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -11,6 +11,7 @@ from typing import Any, ParamSpec, TypeVar, final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_CLOSE_COVER, @@ -250,7 +251,7 @@ class CoverEntity(Entity): """ return self._attr_current_cover_tilt_position - @property + @cached_property def device_class(self) -> CoverDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 51f3a492c47..9227c45aa98 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -8,6 +8,7 @@ from typing import final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DATE from homeassistant.core import HomeAssistant, ServiceCall @@ -75,7 +76,7 @@ class DateEntity(Entity): _attr_native_value: date | None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b04008672ae..c466de922ee 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -8,6 +8,7 @@ from typing import final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv @@ -86,7 +87,7 @@ class DateTimeEntity(Entity): _attr_state: None = None _attr_native_value: datetime | None - @property + @cached_property @final def device_class(self) -> None: """Return entity device class.""" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index e4f9d0e9ab9..642681b43de 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -592,7 +592,10 @@ class DSMREntity(SensorEntity): """Entity is only available if there is a telegram.""" return self.telegram is not None - @property + @property # type: ignore[override] + # The device class can change at runtime from GAS to ENERGY + # when new data is received. This should be remembered and restored + # at startup, but the integration currently doesn't support that. def device_class(self) -> SensorDeviceClass | None: """Return the device class of this entity.""" device_class = super().device_class diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index f6ba2d79bfe..564c77c7604 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -7,6 +7,7 @@ from enum import StrEnum import logging from typing import Any, Self, final +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -114,7 +115,7 @@ class EventEntity(RestoreEntity): __last_event_type: str | None = None __last_event_attributes: dict[str, Any] | None = None - @property + @cached_property def device_class(self) -> EventDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index c240d04ec1a..1b7b3b4bc44 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -220,10 +220,17 @@ class SensorFilter(SensorEntity): self._state: StateType = None self._filters = filters self._attr_icon = None - self._attr_device_class = None + self._device_class = None self._attr_state_class = None self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_id} + @property + # This property is not cached because the underlying source may + # not always be available. + def device_class(self) -> SensorDeviceClass | None: # type: ignore[override] + """Return the device class of the sensor.""" + return self._device_class + @callback def _update_filter_sensor_state_event( self, event: EventType[EventStateChangedData] @@ -283,7 +290,7 @@ class SensorFilter(SensorEntity): self._state = temp_state.state self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) - self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + self._device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) if self._attr_native_unit_of_measurement != new_state.attributes.get( diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index d1e91db8f86..f108383caf6 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -5,6 +5,7 @@ from typing import Any import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, @@ -147,7 +148,7 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): # Set as ON if any / all member is ON self._attr_is_on = self.mode(state == STATE_ON for state in states) - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return the sensor class of the binary sensor.""" return self._device_class diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 10030ab647f..30f0a8d6835 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -360,7 +360,10 @@ class SensorGroup(GroupEntity, SensorEntity): """Return the state attributes of the sensor.""" return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute} - @property + @property # type: ignore[override] + # Because the device class is calculated, there is no guarantee that the + # sensors will be available when the entity is created so we do not want to + # cache the value. def device_class(self) -> SensorDeviceClass | None: """Return device class.""" if self._attr_device_class is not None: diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 193a86a3d37..737e7f13936 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -154,7 +154,10 @@ class HERETravelTimeSensor( ) self.async_write_ha_state() - @property + @property # type: ignore[override] + # This property is not cached because the attribute can change + # at run time. This is not expected, but it is currently how + # the HERE integration works. def attribution(self) -> str | None: """Return the attribution.""" if self.coordinator.data is not None: diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 133b569c751..450c8d1e54e 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -760,7 +760,9 @@ class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): return self.entity_description.icon_fn(self.state) return self.entity_description.icon - @property + @property # type: ignore[override] + # The device class might change at run time of the signal + # is not a number, so we override here. def device_class(self) -> SensorDeviceClass | None: """Return device class for sensor.""" if self.entity_description.device_class_fn: diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index a525c626f14..947dcf2bacc 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -9,6 +9,7 @@ from typing import Any, final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MODE, @@ -158,7 +159,7 @@ class HumidifierEntity(ToggleEntity): return data - @property + @cached_property def device_class(self) -> HumidifierDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 7640925451a..e43778a42c7 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -10,6 +10,7 @@ from typing import Any, Final, TypedDict, final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.components.camera import Image from homeassistant.const import ( ATTR_ENTITY_ID, @@ -156,7 +157,7 @@ class ImageProcessingEntity(Entity): return self.entity_description.confidence return None - @property + @cached_property def device_class(self) -> ImageProcessingDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 66a99b63681..9e7508c1bf1 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -242,6 +242,14 @@ class IntegrationSensor(RestoreSensor): self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None self._attr_device_info = device_info + self._device_class: SensorDeviceClass | None = None + + @property # type: ignore[override] + # The underlying source data may be unavailable at startup, so the device + # class may be set late so we need to override the property to disable the cache. + def device_class(self) -> SensorDeviceClass | None: + """Return the device class of the sensor.""" + return self._device_class def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" @@ -288,7 +296,7 @@ class IntegrationSensor(RestoreSensor): err, ) - self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) + self._device_class = state.attributes.get(ATTR_DEVICE_CLASS) self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @callback @@ -319,7 +327,7 @@ class IntegrationSensor(RestoreSensor): and new_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER ): - self._attr_device_class = SensorDeviceClass.ENERGY + self._device_class = SensorDeviceClass.ENERGY self._attr_icon = None self.async_write_ha_state() diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2acb516fa95..fc908fe1098 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -22,6 +22,7 @@ from aiohttp.typedefs import LooseHeaders import voluptuous as vol from yarl import URL +from homeassistant.backports.functools import cached_property from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR @@ -495,7 +496,7 @@ class MediaPlayerEntity(Entity): _attr_volume_level: float | None = None # Implement these for your media player - @property + @cached_property def device_class(self) -> MediaPlayerDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index 69ecb913c98..65155cbe77e 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -67,7 +67,7 @@ async def async_setup_entry( ) -class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): +class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): # type: ignore[misc] """Representation of an mobile app binary sensor.""" @property diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 120014d1d52..bee2ba96745 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -69,7 +69,9 @@ class MobileAppEntity(RestoreEntity): """Return if entity should be enabled by default.""" return not self._config.get(ATTR_SENSOR_DISABLED) - @property + @property # type: ignore[override,unused-ignore] + # Because the device class is received later from the mobile app + # we do not want to cache the property def device_class(self): """Return the device class.""" return self._config.get(ATTR_SENSOR_DEVICE_CLASS) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index fc325b1b6e9..9e00b45d1e3 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -76,7 +76,7 @@ async def async_setup_entry( ) -class MobileAppSensor(MobileAppEntity, RestoreSensor): +class MobileAppSensor(MobileAppEntity, RestoreSensor): # type: ignore[misc] """Representation of an mobile app sensor.""" async def async_restore_last_state(self, last_state): diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index aa3566c5a95..ff6926261a6 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -12,6 +12,7 @@ from typing import Any, Self, final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -231,7 +232,7 @@ class NumberEntity(Entity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6b4e4a17fc2..b212e509a90 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -11,6 +11,7 @@ import logging from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry # pylint: disable-next=hass-deprecated-import @@ -259,7 +260,7 @@ class SensorEntity(Entity): """ return self.device_class not in (None, SensorDeviceClass.ENUM) - @property + @cached_property def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index e86a4741080..07bccd7522f 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -393,7 +393,9 @@ class StatisticsSensor(SensorEntity): unit = base_unit + "/s" return unit - @property + @property # type: ignore[override] + # Since the underlying data source may not be available at startup + # we disable the caching of device_class. def device_class(self) -> SensorDeviceClass | None: """Return the class of this device.""" if self._state_characteristic in STATS_DATETIME: diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index bf3c3424142..a443fa783cf 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -8,6 +8,7 @@ import logging import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, @@ -102,7 +103,7 @@ class SwitchEntity(ToggleEntity): entity_description: SwitchEntityDescription _attr_device_class: SwitchDeviceClass | None - @property + @cached_property def device_class(self) -> SwitchDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index a04fc7a641d..128b35dffb2 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -294,7 +294,9 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Return the daily forecast in native units.""" return self._forecast_twice_daily - @property + @property # type: ignore[override] + # Because attribution is a template, it can change at any time + # and we don't want to cache it. def attribution(self) -> str | None: """Return the attribution.""" if self._attribution is None: diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 26d40191fb9..6f835514880 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -8,6 +8,7 @@ from typing import final import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall @@ -75,7 +76,7 @@ class TimeEntity(Entity): _attr_device_class: None = None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 668fe479e1f..10aad4625ec 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -552,6 +552,7 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): device: Camera | Light | Sensor entity_description: ProtectBinaryEntityDescription + _device_class: BinarySensorDeviceClass | None @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -561,9 +562,17 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): self._attr_is_on = entity_description.get_ufp_value(updated_device) # UP Sense can be any of the 3 contact sensor device classes if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): - entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( - updated_device.mount_type, BinarySensorDeviceClass.DOOR + self._device_class = MOUNT_DEVICE_CLASS_MAP.get( + self.device.mount_type, BinarySensorDeviceClass.DOOR ) + else: + self._device_class = self.entity_description.device_class + + @property # type: ignore[override] + # UFP smart sensors can change device class at runtime + def device_class(self) -> BinarySensorDeviceClass | None: + """Return the class of this sensor.""" + return self._device_class class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e23032e24fe..e27a9b8e422 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -11,6 +11,7 @@ from typing import Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory @@ -223,7 +224,7 @@ class UpdateEntity(RestoreEntity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index c32bd5eeb67..64d7c8ddb3d 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,6 +8,7 @@ import zigpy.types as t from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasZone +from homeassistant.backports.functools import cached_property from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -195,7 +196,7 @@ class IASZone(BinarySensor): zone_type = self._cluster_handler.cluster.get("zone_type") return IAS_ZONE_NAME_MAPPING.get(zone_type, "iaszone") - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return device class from component DEVICE_CLASSES.""" zone_type = self._cluster_handler.cluster.get("zone_type") diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3c22288a1d6..3ec91d6647b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -645,6 +645,13 @@ class ZwaveSensor(ZWaveBaseEntity, SensorEntity): return None return str(self.info.primary_value.metadata.unit) + @property # type: ignore[override] + # fget is used in the child classes which is not compatible with cached_property + # mypy also doesn't know about fget: https://github.com/python/mypy/issues/6185 + def device_class(self) -> SensorDeviceClass | None: + """Return device class of sensor.""" + return super().device_class + class ZWaveNumericSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor.""" @@ -737,7 +744,9 @@ class ZWaveListSensor(ZwaveSensor): return list(self.info.primary_value.metadata.states.values()) return None - @property + @property # type: ignore[override] + # fget is used which is not compatible with cached_property + # mypy also doesn't know about fget: https://github.com/python/mypy/issues/6185 def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" if (device_class := super().device_class) is not None: @@ -781,7 +790,7 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): additional_info=[property_key_name] if property_key_name else None, ) - @property + @property # type: ignore[override] def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 5ed16408388..ac43e2de956 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -550,7 +550,7 @@ class Entity(ABC): """ return self._attr_device_info - @property + @cached_property def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if hasattr(self, "_attr_device_class"): @@ -639,7 +639,7 @@ class Entity(ABC): return self.entity_description.entity_registry_visible_default return True - @property + @cached_property def attribution(self) -> str | None: """Return the attribution.""" return self._attr_attribution @@ -653,7 +653,7 @@ class Entity(ABC): return self.entity_description.entity_category return None - @property + @cached_property def translation_key(self) -> str | None: """Return the translation key to translate the entity's states.""" if hasattr(self, "_attr_translation_key"): diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 66cda6a088a..7e00180f1fc 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -51,6 +51,7 @@ async def test_event() -> None: event.event_types # Test retrieving data from entity description + del event.device_class event.entity_description = EventEntityDescription( key="test_event", event_types=["short_press", "long_press"], @@ -63,6 +64,7 @@ async def test_event() -> None: event._attr_event_types = ["short_press", "long_press", "double_press"] assert event.event_types == ["short_press", "long_press", "double_press"] event._attr_device_class = EventDeviceClass.BUTTON + del event.device_class assert event.device_class == EventDeviceClass.BUTTON # Test triggering an event diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 73f98c9e2db..68bd62dabfe 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -59,11 +59,13 @@ class MockUpdateEntity(UpdateEntity): """Mock UpdateEntity to use in tests.""" -async def test_update(hass: HomeAssistant) -> None: - """Test getting data from the mocked update entity.""" +def _create_mock_update_entity( + hass: HomeAssistant, +) -> MockUpdateEntity: + mock_platform = MockEntityPlatform(hass) update = MockUpdateEntity() update.hass = hass - update.platform = MockEntityPlatform(hass) + update.platform = mock_platform update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.1" @@ -71,6 +73,13 @@ async def test_update(hass: HomeAssistant) -> None: update._attr_release_url = "https://example.com" update._attr_title = "Title" + return update + + +async def test_update(hass: HomeAssistant) -> None: + """Test getting data from the mocked update entity.""" + update = _create_mock_update_entity(hass) + assert update.entity_category is EntityCategory.DIAGNOSTIC assert ( update.entity_picture @@ -93,7 +102,6 @@ async def test_update(hass: HomeAssistant) -> None: ATTR_SKIPPED_VERSION: None, ATTR_TITLE: "Title", } - # Test no update available update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.0" @@ -120,14 +128,19 @@ async def test_update(hass: HomeAssistant) -> None: assert update.state is STATE_ON # Test entity category becomes config when its possible to install + update = _create_mock_update_entity(hass) update._attr_supported_features = UpdateEntityFeature.INSTALL assert update.entity_category is EntityCategory.CONFIG # UpdateEntityDescription was set + update = _create_mock_update_entity(hass) update._attr_supported_features = 0 update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") assert update.device_class is None assert update.entity_category is EntityCategory.CONFIG + + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update.entity_description = UpdateEntityDescription( key="F5 - Its very refreshing", device_class=UpdateDeviceClass.FIRMWARE, @@ -137,14 +150,24 @@ async def test_update(hass: HomeAssistant) -> None: assert update.entity_category is None # Device class via attribute (override entity description) + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update._attr_device_class = None assert update.device_class is None + + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update._attr_device_class = UpdateDeviceClass.FIRMWARE assert update.device_class is UpdateDeviceClass.FIRMWARE # Entity Attribute via attribute (override entity description) + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update._attr_entity_category = None assert update.entity_category is None + + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update._attr_entity_category = EntityCategory.DIAGNOSTIC assert update.entity_category is EntityCategory.DIAGNOSTIC diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 61ee38a66a7..2961210f5ec 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -98,9 +98,13 @@ class TestHelpersEntity: def setup_method(self, method): """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self._create_entity() + + def _create_entity(self) -> None: self.entity = entity.Entity() self.entity.entity_id = "test.overwrite_hidden_true" - self.hass = self.entity.hass = get_test_home_assistant() + self.entity.hass = self.hass self.entity.schedule_update_ha_state() self.hass.block_till_done() @@ -123,6 +127,7 @@ class TestHelpersEntity: with patch( "homeassistant.helpers.entity.Entity.device_class", new="test_class" ): + self._create_entity() self.entity.schedule_update_ha_state() self.hass.block_till_done() state = self.hass.states.get(self.entity.entity_id) From 6a9c9ca73531542fdf160b231368cd616720b472 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 17:55:56 -0500 Subject: [PATCH 574/984] Improve performance of mqtt_room (#100408) --- homeassistant/components/mqtt_room/sensor.py | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 1b4cdb1c583..4eb3a3f5171 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -2,8 +2,9 @@ from __future__ import annotations from datetime import timedelta -import json +from functools import lru_cache import logging +from typing import Any import voluptuous as vol @@ -24,6 +25,7 @@ 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, slugify +from homeassistant.util.json import json_loads _LOGGER = logging.getLogger(__name__) @@ -47,9 +49,16 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ).extend(mqtt.config.MQTT_RO_SCHEMA.schema) + +@lru_cache(maxsize=256) +def _slugify_upper(string: str) -> str: + """Return a slugified version of string, uppercased.""" + return slugify(string).upper() + + MQTT_PAYLOAD = vol.Schema( vol.All( - json.loads, + json_loads, vol.Schema( { vol.Required(ATTR_ID): cv.string, @@ -106,7 +115,7 @@ class MQTTRoomSensor(SensorEntity): self._state = STATE_NOT_HOME self._name = name self._state_topic = f"{state_topic}/+" - self._device_id = slugify(device_id).upper() + self._device_id = _slugify_upper(device_id) self._timeout = timeout self._consider_home = ( timedelta(seconds=consider_home) if consider_home else None @@ -179,11 +188,10 @@ class MQTTRoomSensor(SensorEntity): self._state = STATE_NOT_HOME -def _parse_update_data(topic, data): +def _parse_update_data(topic: str, data: dict[str, Any]) -> dict[str, Any]: """Parse the room presence update.""" parts = topic.split("/") room = parts[-1] - device_id = slugify(data.get(ATTR_ID)).upper() + device_id = _slugify_upper(data.get(ATTR_ID)) distance = data.get("distance") - parsed_data = {ATTR_DEVICE_ID: device_id, ATTR_ROOM: room, ATTR_DISTANCE: distance} - return parsed_data + return {ATTR_DEVICE_ID: device_id, ATTR_ROOM: room, ATTR_DISTANCE: distance} From b68ceb3ce4ae61127d5200aabd9dda40eb687465 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 21:28:59 -0500 Subject: [PATCH 575/984] Use more shorthand attributes in hyperion (#100213) * Use more shorthand attributes in hyperion There are likely some more here, but I only did the safe ones * Update homeassistant/components/hyperion/switch.py Co-authored-by: Joost Lekkerkerker * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/hyperion/camera.py | 27 ++++++---------- homeassistant/components/hyperion/light.py | 30 +++++------------- homeassistant/components/hyperion/switch.py | 35 +++++++-------------- 3 files changed, 28 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 9c9e509947d..23ce2715140 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -119,7 +119,7 @@ class HyperionCamera(Camera): """Initialize the switch.""" super().__init__() - self._unique_id = get_hyperion_unique_id( + self._attr_unique_id = get_hyperion_unique_id( server_id, instance_num, TYPE_HYPERION_CAMERA ) self._device_id = get_hyperion_device_id(server_id, instance_num) @@ -135,11 +135,13 @@ class HyperionCamera(Camera): self._client_callbacks = { f"{KEY_LEDCOLORS}-{KEY_IMAGE_STREAM}-{KEY_UPDATE}": self._update_imagestream } - - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=instance_name, + configuration_url=hyperion_client.remote_url, + ) @property def is_on(self) -> bool: @@ -231,7 +233,7 @@ class HyperionCamera(Camera): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_ENTITY_REMOVE.format(self._unique_id), + SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id), functools.partial(self.async_remove, force_remove=True), ) ) @@ -242,17 +244,6 @@ class HyperionCamera(Camera): """Cleanup prior to hass removal.""" self._client.remove_callbacks(self._client_callbacks) - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - CAMERA_TYPES = { TYPE_HYPERION_CAMERA: HyperionCamera, diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 105e577efad..824d83591ef 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -132,7 +132,7 @@ class HyperionLight(LightEntity): hyperion_client: client.HyperionClient, ) -> None: """Initialize the light.""" - self._unique_id = self._compute_unique_id(server_id, instance_num) + self._attr_unique_id = self._compute_unique_id(server_id, instance_num) self._device_id = get_hyperion_device_id(server_id, instance_num) self._instance_name = instance_name self._options = options @@ -153,16 +153,18 @@ class HyperionLight(LightEntity): f"{const.KEY_PRIORITIES}-{const.KEY_UPDATE}": self._update_priorities, f"{const.KEY_CLIENT}-{const.KEY_UPDATE}": self._update_client, } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=self._instance_name, + configuration_url=self._client.remote_url, + ) def _compute_unique_id(self, server_id: str, instance_num: int) -> str: """Compute a unique id for this instance.""" return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) - @property - def entity_registry_enabled_default(self) -> bool: - """Whether or not the entity is enabled by default.""" - return True - @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" @@ -196,22 +198,6 @@ class HyperionLight(LightEntity): """Return server availability.""" return bool(self._client.has_loaded_state) - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - def _get_option(self, key: str) -> Any: """Get a value from the provided options.""" defaults = { diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 11e1dc199be..eb7b260a370 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -133,6 +133,8 @@ class HyperionComponentSwitch(SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _attr_should_poll = False _attr_has_entity_name = True + # These component controls are for advanced users and are disabled by default. + _attr_entity_registry_enabled_default = False def __init__( self, @@ -143,7 +145,7 @@ class HyperionComponentSwitch(SwitchEntity): hyperion_client: client.HyperionClient, ) -> None: """Initialize the switch.""" - self._unique_id = _component_to_unique_id( + self._attr_unique_id = _component_to_unique_id( server_id, component_name, instance_num ) self._device_id = get_hyperion_device_id(server_id, instance_num) @@ -154,17 +156,13 @@ class HyperionComponentSwitch(SwitchEntity): self._client_callbacks = { f"{KEY_COMPONENTS}-{KEY_UPDATE}": self._update_components } - - @property - def entity_registry_enabled_default(self) -> bool: - """Whether or not the entity is enabled by default.""" - # These component controls are for advanced users and are disabled by default. - return False - - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=self._instance_name, + configuration_url=self._client.remote_url, + ) @property def is_on(self) -> bool: @@ -179,17 +177,6 @@ class HyperionComponentSwitch(SwitchEntity): """Return server availability.""" return bool(self._client.has_loaded_state) - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - async def _async_send_set_component(self, value: bool) -> None: """Send a component control request.""" await self._client.async_send_set_component( @@ -219,7 +206,7 @@ class HyperionComponentSwitch(SwitchEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_ENTITY_REMOVE.format(self._unique_id), + SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id), functools.partial(self.async_remove, force_remove=True), ) ) From 772ac9766bb9a247e6ac19bc0481611005870943 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 15 Sep 2023 07:52:29 +0200 Subject: [PATCH 576/984] Move awair coordinators to their own file (#100411) * Move awair coordinators to their file * Add awair/coordinator.py to .coveragerc --- .coveragerc | 1 + homeassistant/components/awair/__init__.py | 115 +---------------- homeassistant/components/awair/coordinator.py | 116 ++++++++++++++++++ homeassistant/components/awair/sensor.py | 2 +- 4 files changed, 124 insertions(+), 110 deletions(-) create mode 100644 homeassistant/components/awair/coordinator.py diff --git a/.coveragerc b/.coveragerc index 015d1c541e9..2f43fe3ab3e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -101,6 +101,7 @@ omit = homeassistant/components/azure_devops/__init__.py homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* + homeassistant/components/awair/coordinator.py homeassistant/components/baf/__init__.py homeassistant/components/baf/climate.py homeassistant/components/baf/entity.py diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 083c7d48b03..cb974707e93 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -1,29 +1,16 @@ """The awair component.""" from __future__ import annotations -from asyncio import gather, timeout -from dataclasses import dataclass -from datetime import timedelta - -from aiohttp import ClientSession -from python_awair import Awair, AwairLocal -from python_awair.air_data import AirData -from python_awair.devices import AwairBaseDevice, AwairLocalDevice -from python_awair.exceptions import AuthError, AwairError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.const import CONF_HOST, Platform 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 ( - API_TIMEOUT, - DOMAIN, - LOGGER, - UPDATE_INTERVAL_CLOUD, - UPDATE_INTERVAL_LOCAL, +from .const import DOMAIN +from .coordinator import ( + AwairCloudDataUpdateCoordinator, + AwairDataUpdateCoordinator, + AwairLocalDataUpdateCoordinator, ) PLATFORMS = [Platform.SENSOR] @@ -70,93 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok - - -@dataclass -class AwairResult: - """Wrapper class to hold an awair device and set of air data.""" - - device: AwairBaseDevice - air_data: AirData - - -class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): - """Define a wrapper class to update Awair data.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - update_interval: timedelta | None, - ) -> None: - """Set up the AwairDataUpdateCoordinator class.""" - self._config_entry = config_entry - self.title = config_entry.title - - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _fetch_air_data(self, device: AwairBaseDevice) -> AwairResult: - """Fetch latest air quality data.""" - LOGGER.debug("Fetching data for %s", device.uuid) - air_data = await device.air_data_latest() - LOGGER.debug(air_data) - return AwairResult(device=device, air_data=air_data) - - -class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator): - """Define a wrapper class to update Awair data from Cloud API.""" - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession - ) -> None: - """Set up the AwairCloudDataUpdateCoordinator class.""" - access_token = config_entry.data[CONF_ACCESS_TOKEN] - self._awair = Awair(access_token=access_token, session=session) - - super().__init__(hass, config_entry, UPDATE_INTERVAL_CLOUD) - - async def _async_update_data(self) -> dict[str, AwairResult]: - """Update data via Awair client library.""" - async with timeout(API_TIMEOUT): - try: - LOGGER.debug("Fetching users and devices") - user = await self._awair.user() - devices = await user.devices() - results = await gather( - *(self._fetch_air_data(device) for device in devices) - ) - return {result.device.uuid: result for result in results} - except AuthError as err: - raise ConfigEntryAuthFailed from err - except Exception as err: - raise UpdateFailed(err) from err - - -class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator): - """Define a wrapper class to update Awair data from the local API.""" - - _device: AwairLocalDevice | None = None - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession - ) -> None: - """Set up the AwairLocalDataUpdateCoordinator class.""" - self._awair = AwairLocal( - session=session, device_addrs=[config_entry.data[CONF_HOST]] - ) - - super().__init__(hass, config_entry, UPDATE_INTERVAL_LOCAL) - - async def _async_update_data(self) -> dict[str, AwairResult]: - """Update data via Awair client library.""" - async with timeout(API_TIMEOUT): - try: - if self._device is None: - LOGGER.debug("Fetching devices") - devices = await self._awair.devices() - self._device = devices[0] - result = await self._fetch_air_data(self._device) - return {result.device.uuid: result} - except AwairError as err: - LOGGER.error("Unexpected API error: %s", err) - raise UpdateFailed(err) from err diff --git a/homeassistant/components/awair/coordinator.py b/homeassistant/components/awair/coordinator.py new file mode 100644 index 00000000000..b687a916a2d --- /dev/null +++ b/homeassistant/components/awair/coordinator.py @@ -0,0 +1,116 @@ +"""DataUpdateCoordinators for awair integration.""" +from __future__ import annotations + +from asyncio import gather, timeout +from dataclasses import dataclass +from datetime import timedelta + +from aiohttp import ClientSession +from python_awair import Awair, AwairLocal +from python_awair.air_data import AirData +from python_awair.devices import AwairBaseDevice, AwairLocalDevice +from python_awair.exceptions import AuthError, AwairError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + API_TIMEOUT, + DOMAIN, + LOGGER, + UPDATE_INTERVAL_CLOUD, + UPDATE_INTERVAL_LOCAL, +) + + +@dataclass +class AwairResult: + """Wrapper class to hold an awair device and set of air data.""" + + device: AwairBaseDevice + air_data: AirData + + +class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): + """Define a wrapper class to update Awair data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + update_interval: timedelta | None, + ) -> None: + """Set up the AwairDataUpdateCoordinator class.""" + self._config_entry = config_entry + self.title = config_entry.title + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _fetch_air_data(self, device: AwairBaseDevice) -> AwairResult: + """Fetch latest air quality data.""" + LOGGER.debug("Fetching data for %s", device.uuid) + air_data = await device.air_data_latest() + LOGGER.debug(air_data) + return AwairResult(device=device, air_data=air_data) + + +class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator): + """Define a wrapper class to update Awair data from Cloud API.""" + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + ) -> None: + """Set up the AwairCloudDataUpdateCoordinator class.""" + access_token = config_entry.data[CONF_ACCESS_TOKEN] + self._awair = Awair(access_token=access_token, session=session) + + super().__init__(hass, config_entry, UPDATE_INTERVAL_CLOUD) + + async def _async_update_data(self) -> dict[str, AwairResult]: + """Update data via Awair client library.""" + async with timeout(API_TIMEOUT): + try: + LOGGER.debug("Fetching users and devices") + user = await self._awair.user() + devices = await user.devices() + results = await gather( + *(self._fetch_air_data(device) for device in devices) + ) + return {result.device.uuid: result for result in results} + except AuthError as err: + raise ConfigEntryAuthFailed from err + except Exception as err: + raise UpdateFailed(err) from err + + +class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator): + """Define a wrapper class to update Awair data from the local API.""" + + _device: AwairLocalDevice | None = None + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + ) -> None: + """Set up the AwairLocalDataUpdateCoordinator class.""" + self._awair = AwairLocal( + session=session, device_addrs=[config_entry.data[CONF_HOST]] + ) + + super().__init__(hass, config_entry, UPDATE_INTERVAL_LOCAL) + + async def _async_update_data(self) -> dict[str, AwairResult]: + """Update data via Awair client library.""" + async with timeout(API_TIMEOUT): + try: + if self._device is None: + LOGGER.debug("Fetching devices") + devices = await self._awair.devices() + self._device = devices[0] + result = await self._fetch_air_data(self._device) + return {result.device.uuid: result} + except AwairError as err: + LOGGER.error("Unexpected API error: %s", err) + raise UpdateFailed(err) from err diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 27962167330..2a09a8d4e70 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -31,7 +31,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AwairDataUpdateCoordinator, AwairResult from .const import ( API_CO2, API_DUST, @@ -46,6 +45,7 @@ from .const import ( ATTRIBUTION, DOMAIN, ) +from .coordinator import AwairDataUpdateCoordinator, AwairResult DUST_ALIASES = [API_PM25, API_PM10] From 9470c71d49747502c1cdfbeb12d2833dafd455de Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 15 Sep 2023 06:52:50 +0100 Subject: [PATCH 577/984] Fix current condition in IPMA (#100412) always use hourly forecast to retrieve current weather condition. fix #100393 --- homeassistant/components/ipma/weather.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index a5bb3981575..f9b93cbe954 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -103,10 +103,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): else: self._daily_forecast = None - if self._period == 1 or self._forecast_listeners["hourly"]: - await self._update_forecast("hourly", 1, True) - else: - self._hourly_forecast = None + await self._update_forecast("hourly", 1, True) _LOGGER.debug( "Updated location %s based on %s, current observation %s", @@ -139,8 +136,8 @@ class IPMAWeather(WeatherEntity, IPMADevice): @property def condition(self): - """Return the current condition.""" - forecast = self._hourly_forecast or self._daily_forecast + """Return the current condition which is only available on the hourly forecast data.""" + forecast = self._hourly_forecast if not forecast: return From a70235046aba7181eaa97023254e72e8c3551fc5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Sep 2023 08:07:27 +0200 Subject: [PATCH 578/984] Tweak datetime service schema (#100380) --- homeassistant/components/datetime/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index c466de922ee..b17a8d65250 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -13,7 +13,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 - ENTITY_SERVICE_FIELDS, PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) @@ -54,7 +53,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SERVICE_SET_VALUE, { vol.Required(ATTR_DATETIME): cv.datetime, - **ENTITY_SERVICE_FIELDS, }, _async_set_value, ) From a8013836e10e89e2fc1f30a2cf6ffb13faf118d7 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 14 Sep 2023 23:28:27 -0700 Subject: [PATCH 579/984] Bump apple_weatherkit to 1.0.3 (#100416) --- 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 1e8bb8ba5c5..34a5d45ca1f 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.2"] + "requirements": ["apple_weatherkit==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b9db7f55bdd..0304faa1f08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -424,7 +424,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.2 +apple_weatherkit==1.0.3 # homeassistant.components.apprise apprise==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bee67b6747..6063842f5fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -390,7 +390,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.2 +apple_weatherkit==1.0.3 # homeassistant.components.apprise apprise==1.5.0 From 7723a9b36b547e9851b4181dfb3799ed324fcaaa Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 15 Sep 2023 10:04:41 +0200 Subject: [PATCH 580/984] Move airtouch4 coordinator to its own file (#100424) --- .coveragerc | 1 + .../components/airtouch4/__init__.py | 43 +---------------- .../components/airtouch4/coordinator.py | 46 +++++++++++++++++++ 3 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/airtouch4/coordinator.py diff --git a/.coveragerc b/.coveragerc index 2f43fe3ab3e..ddde800cd77 100644 --- a/.coveragerc +++ b/.coveragerc @@ -45,6 +45,7 @@ omit = homeassistant/components/airthings_ble/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py + homeassistant/components/airtouch4/coordinator.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/airvisual_pro/__init__.py diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index a2c3f716ab1..dc5172096a7 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -1,19 +1,13 @@ """The AirTouch4 integration.""" -import logging - from airtouch4pyapi import AirTouch -from airtouch4pyapi.airtouch import AirTouchStatus -from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import AirtouchDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE] @@ -44,38 +38,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Airtouch data.""" - - def __init__(self, hass, airtouch): - """Initialize global Airtouch data updater.""" - self.airtouch = airtouch - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from Airtouch.""" - await self.airtouch.UpdateInfo() - if self.airtouch.Status != AirTouchStatus.OK: - raise UpdateFailed("Airtouch connection issue") - return { - "acs": [ - {"ac_number": ac.AcNumber, "is_on": ac.IsOn} - for ac in self.airtouch.GetAcs() - ], - "groups": [ - { - "group_number": group.GroupNumber, - "group_name": group.GroupName, - "is_on": group.IsOn, - } - for group in self.airtouch.GetGroups() - ], - } diff --git a/homeassistant/components/airtouch4/coordinator.py b/homeassistant/components/airtouch4/coordinator.py new file mode 100644 index 00000000000..e78bf62dbd0 --- /dev/null +++ b/homeassistant/components/airtouch4/coordinator.py @@ -0,0 +1,46 @@ +"""DataUpdateCoordinator for the airtouch integration.""" +import logging + +from airtouch4pyapi.airtouch import AirTouchStatus + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Airtouch data.""" + + def __init__(self, hass, airtouch): + """Initialize global Airtouch data updater.""" + self.airtouch = airtouch + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Airtouch.""" + await self.airtouch.UpdateInfo() + if self.airtouch.Status != AirTouchStatus.OK: + raise UpdateFailed("Airtouch connection issue") + return { + "acs": [ + {"ac_number": ac.AcNumber, "is_on": ac.IsOn} + for ac in self.airtouch.GetAcs() + ], + "groups": [ + { + "group_number": group.GroupNumber, + "group_name": group.GroupName, + "is_on": group.IsOn, + } + for group in self.airtouch.GetGroups() + ], + } From d1afcd773faa05f26cd9a2a7da64b5dda6b24016 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Sep 2023 11:25:24 +0200 Subject: [PATCH 581/984] Revert "Cache entity properties that are never expected to change in the base class" (#100422) Revert "Cache entity properties that are never expected to change in the base class (#95315)" This reverts commit 042776ebb82924d39ab706f9f3907967a2730eb5. --- homeassistant/backports/functools.py | 12 +++---- .../components/abode/binary_sensor.py | 4 +-- .../components/binary_sensor/__init__.py | 3 +- homeassistant/components/button/__init__.py | 3 +- homeassistant/components/cover/__init__.py | 3 +- homeassistant/components/date/__init__.py | 3 +- homeassistant/components/datetime/__init__.py | 3 +- homeassistant/components/dsmr/sensor.py | 5 +-- homeassistant/components/event/__init__.py | 3 +- homeassistant/components/filter/sensor.py | 11 ++----- .../components/group/binary_sensor.py | 3 +- homeassistant/components/group/sensor.py | 5 +-- .../components/here_travel_time/sensor.py | 5 +-- homeassistant/components/huawei_lte/sensor.py | 4 +-- .../components/humidifier/__init__.py | 3 +- .../components/image_processing/__init__.py | 3 +- .../components/integration/sensor.py | 12 ++----- .../components/media_player/__init__.py | 3 +- .../components/mobile_app/binary_sensor.py | 2 +- homeassistant/components/mobile_app/entity.py | 4 +-- homeassistant/components/mobile_app/sensor.py | 2 +- homeassistant/components/number/__init__.py | 3 +- homeassistant/components/sensor/__init__.py | 3 +- homeassistant/components/statistics/sensor.py | 4 +-- homeassistant/components/switch/__init__.py | 3 +- homeassistant/components/template/weather.py | 4 +-- homeassistant/components/time/__init__.py | 3 +- .../components/unifiprotect/binary_sensor.py | 13 ++------ homeassistant/components/update/__init__.py | 3 +- homeassistant/components/zha/binary_sensor.py | 3 +- homeassistant/components/zwave_js/sensor.py | 13 ++------ homeassistant/helpers/entity.py | 6 ++-- tests/components/event/test_init.py | 2 -- tests/components/update/test_init.py | 31 +++---------------- tests/helpers/test_entity.py | 7 +---- 35 files changed, 48 insertions(+), 146 deletions(-) diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index f031004685c..212c8516b48 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -5,18 +5,18 @@ from collections.abc import Callable from types import GenericAlias from typing import Any, Generic, Self, TypeVar, overload -_T_co = TypeVar("_T_co", covariant=True) +_T = TypeVar("_T") -class cached_property(Generic[_T_co]): # pylint: disable=invalid-name +class cached_property(Generic[_T]): """Backport of Python 3.12's cached_property. Includes https://github.com/python/cpython/pull/101890/files """ - def __init__(self, func: Callable[[Any], _T_co]) -> None: + def __init__(self, func: Callable[[Any], _T]) -> None: """Initialize.""" - self.func: Callable[[Any], _T_co] = func + self.func: Callable[[Any], _T] = func self.attrname: str | None = None self.__doc__ = func.__doc__ @@ -35,12 +35,12 @@ class cached_property(Generic[_T_co]): # pylint: disable=invalid-name ... @overload - def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T_co: + def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T: ... def __get__( self, instance: Any | None, owner: type[Any] | None = None - ) -> _T_co | Self: + ) -> _T | Self: """Get.""" if instance is None: return self diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index 43f0b8a289c..a10dbc8e664 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -50,9 +50,7 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorEntity): """Return True if the binary sensor is on.""" return cast(bool, self._device.is_on) - @property # type: ignore[override] - # We don't know if the class may be set late here - # so we need to override the property to disable the cache. + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of the binary sensor.""" if self._device.get_value("is_window") == "1": diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index f0b5d6e1d03..79e20c6f571 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -9,7 +9,6 @@ from typing import Literal, final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -198,7 +197,7 @@ class BinarySensorEntity(Entity): """ return self.device_class is not None - @cached_property + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 735470033c9..901acdcdec1 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -9,7 +9,6 @@ from typing import final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -97,7 +96,7 @@ class ButtonEntity(RestoreEntity): """ return self.device_class is not None - @cached_property + @property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 5fae199c961..354b972e2b7 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -11,7 +11,6 @@ from typing import Any, ParamSpec, TypeVar, final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_CLOSE_COVER, @@ -251,7 +250,7 @@ class CoverEntity(Entity): """ return self._attr_current_cover_tilt_position - @cached_property + @property def device_class(self) -> CoverDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 9227c45aa98..51f3a492c47 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -8,7 +8,6 @@ from typing import final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DATE from homeassistant.core import HomeAssistant, ServiceCall @@ -76,7 +75,7 @@ class DateEntity(Entity): _attr_native_value: date | None _attr_state: None = None - @cached_property + @property @final def device_class(self) -> None: """Return the device class for the entity.""" diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b17a8d65250..e25f4535d0c 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -8,7 +8,6 @@ from typing import final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv @@ -85,7 +84,7 @@ class DateTimeEntity(Entity): _attr_state: None = None _attr_native_value: datetime | None - @cached_property + @property @final def device_class(self) -> None: """Return entity device class.""" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 642681b43de..e4f9d0e9ab9 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -592,10 +592,7 @@ class DSMREntity(SensorEntity): """Entity is only available if there is a telegram.""" return self.telegram is not None - @property # type: ignore[override] - # The device class can change at runtime from GAS to ENERGY - # when new data is received. This should be remembered and restored - # at startup, but the integration currently doesn't support that. + @property def device_class(self) -> SensorDeviceClass | None: """Return the device class of this entity.""" device_class = super().device_class diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index 564c77c7604..f6ba2d79bfe 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -7,7 +7,6 @@ from enum import StrEnum import logging from typing import Any, Self, final -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -115,7 +114,7 @@ class EventEntity(RestoreEntity): __last_event_type: str | None = None __last_event_attributes: dict[str, Any] | None = None - @cached_property + @property def device_class(self) -> EventDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 1b7b3b4bc44..c240d04ec1a 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -220,17 +220,10 @@ class SensorFilter(SensorEntity): self._state: StateType = None self._filters = filters self._attr_icon = None - self._device_class = None + self._attr_device_class = None self._attr_state_class = None self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_id} - @property - # This property is not cached because the underlying source may - # not always be available. - def device_class(self) -> SensorDeviceClass | None: # type: ignore[override] - """Return the device class of the sensor.""" - return self._device_class - @callback def _update_filter_sensor_state_event( self, event: EventType[EventStateChangedData] @@ -290,7 +283,7 @@ class SensorFilter(SensorEntity): self._state = temp_state.state self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) - self._device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) if self._attr_native_unit_of_measurement != new_state.attributes.get( diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index f108383caf6..d1e91db8f86 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -5,7 +5,6 @@ from typing import Any import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, @@ -148,7 +147,7 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): # Set as ON if any / all member is ON self._attr_is_on = self.mode(state == STATE_ON for state in states) - @cached_property + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return the sensor class of the binary sensor.""" return self._device_class diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 30f0a8d6835..10030ab647f 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -360,10 +360,7 @@ class SensorGroup(GroupEntity, SensorEntity): """Return the state attributes of the sensor.""" return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute} - @property # type: ignore[override] - # Because the device class is calculated, there is no guarantee that the - # sensors will be available when the entity is created so we do not want to - # cache the value. + @property def device_class(self) -> SensorDeviceClass | None: """Return device class.""" if self._attr_device_class is not None: diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 737e7f13936..193a86a3d37 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -154,10 +154,7 @@ class HERETravelTimeSensor( ) self.async_write_ha_state() - @property # type: ignore[override] - # This property is not cached because the attribute can change - # at run time. This is not expected, but it is currently how - # the HERE integration works. + @property def attribution(self) -> str | None: """Return the attribution.""" if self.coordinator.data is not None: diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 450c8d1e54e..133b569c751 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -760,9 +760,7 @@ class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): return self.entity_description.icon_fn(self.state) return self.entity_description.icon - @property # type: ignore[override] - # The device class might change at run time of the signal - # is not a number, so we override here. + @property def device_class(self) -> SensorDeviceClass | None: """Return device class for sensor.""" if self.entity_description.device_class_fn: diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 947dcf2bacc..a525c626f14 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -9,7 +9,6 @@ from typing import Any, final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MODE, @@ -159,7 +158,7 @@ class HumidifierEntity(ToggleEntity): return data - @cached_property + @property def device_class(self) -> HumidifierDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index e43778a42c7..7640925451a 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -10,7 +10,6 @@ from typing import Any, Final, TypedDict, final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.components.camera import Image from homeassistant.const import ( ATTR_ENTITY_ID, @@ -157,7 +156,7 @@ class ImageProcessingEntity(Entity): return self.entity_description.confidence return None - @cached_property + @property def device_class(self) -> ImageProcessingDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 9e7508c1bf1..66a99b63681 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -242,14 +242,6 @@ class IntegrationSensor(RestoreSensor): self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None self._attr_device_info = device_info - self._device_class: SensorDeviceClass | None = None - - @property # type: ignore[override] - # The underlying source data may be unavailable at startup, so the device - # class may be set late so we need to override the property to disable the cache. - def device_class(self) -> SensorDeviceClass | None: - """Return the device class of the sensor.""" - return self._device_class def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" @@ -296,7 +288,7 @@ class IntegrationSensor(RestoreSensor): err, ) - self._device_class = state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @callback @@ -327,7 +319,7 @@ class IntegrationSensor(RestoreSensor): and new_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER ): - self._device_class = SensorDeviceClass.ENERGY + self._attr_device_class = SensorDeviceClass.ENERGY self._attr_icon = None self.async_write_ha_state() diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index fc908fe1098..2acb516fa95 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -22,7 +22,6 @@ from aiohttp.typedefs import LooseHeaders import voluptuous as vol from yarl import URL -from homeassistant.backports.functools import cached_property from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR @@ -496,7 +495,7 @@ class MediaPlayerEntity(Entity): _attr_volume_level: float | None = None # Implement these for your media player - @cached_property + @property def device_class(self) -> MediaPlayerDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index 65155cbe77e..69ecb913c98 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -67,7 +67,7 @@ async def async_setup_entry( ) -class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): # type: ignore[misc] +class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): """Representation of an mobile app binary sensor.""" @property diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index bee2ba96745..120014d1d52 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -69,9 +69,7 @@ class MobileAppEntity(RestoreEntity): """Return if entity should be enabled by default.""" return not self._config.get(ATTR_SENSOR_DISABLED) - @property # type: ignore[override,unused-ignore] - # Because the device class is received later from the mobile app - # we do not want to cache the property + @property def device_class(self): """Return the device class.""" return self._config.get(ATTR_SENSOR_DEVICE_CLASS) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 9e00b45d1e3..fc325b1b6e9 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -76,7 +76,7 @@ async def async_setup_entry( ) -class MobileAppSensor(MobileAppEntity, RestoreSensor): # type: ignore[misc] +class MobileAppSensor(MobileAppEntity, RestoreSensor): """Representation of an mobile app sensor.""" async def async_restore_last_state(self, last_state): diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index ff6926261a6..aa3566c5a95 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -12,7 +12,6 @@ from typing import Any, Self, final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -232,7 +231,7 @@ class NumberEntity(Entity): """ return self.device_class is not None - @cached_property + @property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index b212e509a90..6b4e4a17fc2 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -11,7 +11,6 @@ import logging from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry # pylint: disable-next=hass-deprecated-import @@ -260,7 +259,7 @@ class SensorEntity(Entity): """ return self.device_class not in (None, SensorDeviceClass.ENUM) - @cached_property + @property def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 07bccd7522f..e86a4741080 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -393,9 +393,7 @@ class StatisticsSensor(SensorEntity): unit = base_unit + "/s" return unit - @property # type: ignore[override] - # Since the underlying data source may not be available at startup - # we disable the caching of device_class. + @property def device_class(self) -> SensorDeviceClass | None: """Return the class of this device.""" if self._state_characteristic in STATS_DATETIME: diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index a443fa783cf..bf3c3424142 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -8,7 +8,6 @@ import logging import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, @@ -103,7 +102,7 @@ class SwitchEntity(ToggleEntity): entity_description: SwitchEntityDescription _attr_device_class: SwitchDeviceClass | None - @cached_property + @property def device_class(self) -> SwitchDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 128b35dffb2..a04fc7a641d 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -294,9 +294,7 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Return the daily forecast in native units.""" return self._forecast_twice_daily - @property # type: ignore[override] - # Because attribution is a template, it can change at any time - # and we don't want to cache it. + @property def attribution(self) -> str | None: """Return the attribution.""" if self._attribution is None: diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 6f835514880..26d40191fb9 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -8,7 +8,6 @@ from typing import final import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall @@ -76,7 +75,7 @@ class TimeEntity(Entity): _attr_device_class: None = None _attr_state: None = None - @cached_property + @property @final def device_class(self) -> None: """Return the device class for the entity.""" diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 10aad4625ec..668fe479e1f 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -552,7 +552,6 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): device: Camera | Light | Sensor entity_description: ProtectBinaryEntityDescription - _device_class: BinarySensorDeviceClass | None @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -562,17 +561,9 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): self._attr_is_on = entity_description.get_ufp_value(updated_device) # UP Sense can be any of the 3 contact sensor device classes if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): - self._device_class = MOUNT_DEVICE_CLASS_MAP.get( - self.device.mount_type, BinarySensorDeviceClass.DOOR + entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( + updated_device.mount_type, BinarySensorDeviceClass.DOOR ) - else: - self._device_class = self.entity_description.device_class - - @property # type: ignore[override] - # UFP smart sensors can change device class at runtime - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this sensor.""" - return self._device_class class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e27a9b8e422..e23032e24fe 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -11,7 +11,6 @@ from typing import Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory @@ -224,7 +223,7 @@ class UpdateEntity(RestoreEntity): """ return self.device_class is not None - @cached_property + @property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 64d7c8ddb3d..c32bd5eeb67 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,7 +8,6 @@ import zigpy.types as t from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasZone -from homeassistant.backports.functools import cached_property from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -196,7 +195,7 @@ class IASZone(BinarySensor): zone_type = self._cluster_handler.cluster.get("zone_type") return IAS_ZONE_NAME_MAPPING.get(zone_type, "iaszone") - @cached_property + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return device class from component DEVICE_CLASSES.""" zone_type = self._cluster_handler.cluster.get("zone_type") diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3ec91d6647b..3c22288a1d6 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -645,13 +645,6 @@ class ZwaveSensor(ZWaveBaseEntity, SensorEntity): return None return str(self.info.primary_value.metadata.unit) - @property # type: ignore[override] - # fget is used in the child classes which is not compatible with cached_property - # mypy also doesn't know about fget: https://github.com/python/mypy/issues/6185 - def device_class(self) -> SensorDeviceClass | None: - """Return device class of sensor.""" - return super().device_class - class ZWaveNumericSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor.""" @@ -744,9 +737,7 @@ class ZWaveListSensor(ZwaveSensor): return list(self.info.primary_value.metadata.states.values()) return None - @property # type: ignore[override] - # fget is used which is not compatible with cached_property - # mypy also doesn't know about fget: https://github.com/python/mypy/issues/6185 + @property def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" if (device_class := super().device_class) is not None: @@ -790,7 +781,7 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): additional_info=[property_key_name] if property_key_name else None, ) - @property # type: ignore[override] + @property def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index ac43e2de956..5ed16408388 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -550,7 +550,7 @@ class Entity(ABC): """ return self._attr_device_info - @cached_property + @property def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if hasattr(self, "_attr_device_class"): @@ -639,7 +639,7 @@ class Entity(ABC): return self.entity_description.entity_registry_visible_default return True - @cached_property + @property def attribution(self) -> str | None: """Return the attribution.""" return self._attr_attribution @@ -653,7 +653,7 @@ class Entity(ABC): return self.entity_description.entity_category return None - @cached_property + @property def translation_key(self) -> str | None: """Return the translation key to translate the entity's states.""" if hasattr(self, "_attr_translation_key"): diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 7e00180f1fc..66cda6a088a 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -51,7 +51,6 @@ async def test_event() -> None: event.event_types # Test retrieving data from entity description - del event.device_class event.entity_description = EventEntityDescription( key="test_event", event_types=["short_press", "long_press"], @@ -64,7 +63,6 @@ async def test_event() -> None: event._attr_event_types = ["short_press", "long_press", "double_press"] assert event.event_types == ["short_press", "long_press", "double_press"] event._attr_device_class = EventDeviceClass.BUTTON - del event.device_class assert event.device_class == EventDeviceClass.BUTTON # Test triggering an event diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 68bd62dabfe..73f98c9e2db 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -59,13 +59,11 @@ class MockUpdateEntity(UpdateEntity): """Mock UpdateEntity to use in tests.""" -def _create_mock_update_entity( - hass: HomeAssistant, -) -> MockUpdateEntity: - mock_platform = MockEntityPlatform(hass) +async def test_update(hass: HomeAssistant) -> None: + """Test getting data from the mocked update entity.""" update = MockUpdateEntity() update.hass = hass - update.platform = mock_platform + update.platform = MockEntityPlatform(hass) update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.1" @@ -73,13 +71,6 @@ def _create_mock_update_entity( update._attr_release_url = "https://example.com" update._attr_title = "Title" - return update - - -async def test_update(hass: HomeAssistant) -> None: - """Test getting data from the mocked update entity.""" - update = _create_mock_update_entity(hass) - assert update.entity_category is EntityCategory.DIAGNOSTIC assert ( update.entity_picture @@ -102,6 +93,7 @@ async def test_update(hass: HomeAssistant) -> None: ATTR_SKIPPED_VERSION: None, ATTR_TITLE: "Title", } + # Test no update available update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.0" @@ -128,19 +120,14 @@ async def test_update(hass: HomeAssistant) -> None: assert update.state is STATE_ON # Test entity category becomes config when its possible to install - update = _create_mock_update_entity(hass) update._attr_supported_features = UpdateEntityFeature.INSTALL assert update.entity_category is EntityCategory.CONFIG # UpdateEntityDescription was set - update = _create_mock_update_entity(hass) update._attr_supported_features = 0 update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") assert update.device_class is None assert update.entity_category is EntityCategory.CONFIG - - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update.entity_description = UpdateEntityDescription( key="F5 - Its very refreshing", device_class=UpdateDeviceClass.FIRMWARE, @@ -150,24 +137,14 @@ async def test_update(hass: HomeAssistant) -> None: assert update.entity_category is None # Device class via attribute (override entity description) - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update._attr_device_class = None assert update.device_class is None - - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update._attr_device_class = UpdateDeviceClass.FIRMWARE assert update.device_class is UpdateDeviceClass.FIRMWARE # Entity Attribute via attribute (override entity description) - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update._attr_entity_category = None assert update.entity_category is None - - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update._attr_entity_category = EntityCategory.DIAGNOSTIC assert update.entity_category is EntityCategory.DIAGNOSTIC diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2961210f5ec..61ee38a66a7 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -98,13 +98,9 @@ class TestHelpersEntity: def setup_method(self, method): """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self._create_entity() - - def _create_entity(self) -> None: self.entity = entity.Entity() self.entity.entity_id = "test.overwrite_hidden_true" - self.entity.hass = self.hass + self.hass = self.entity.hass = get_test_home_assistant() self.entity.schedule_update_ha_state() self.hass.block_till_done() @@ -127,7 +123,6 @@ class TestHelpersEntity: with patch( "homeassistant.helpers.entity.Entity.device_class", new="test_class" ): - self._create_entity() self.entity.schedule_update_ha_state() self.hass.block_till_done() state = self.hass.states.get(self.entity.entity_id) From 1737b27dd4eac3d829bb342cd072a034910e7a11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Sep 2023 12:58:56 +0200 Subject: [PATCH 582/984] Generate withings webhook ID in config flow (#100395) --- homeassistant/components/withings/config_flow.py | 5 +++-- tests/components/withings/test_config_flow.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index cce1c5ee23c..4dd123468a0 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -8,8 +8,9 @@ from typing import Any import voluptuous as vol from withings_api.common import AuthScope +from homeassistant.components.webhook import async_generate_id from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -77,7 +78,7 @@ class WithingsFlowHandler( return self.async_create_entry( title=DEFAULT_TITLE, - data=data, + data={**data, CONF_WEBHOOK_ID: async_generate_id()}, options={CONF_USE_WEBHOOK: False}, ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 52a584e2513..d5745ae9bed 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -73,6 +73,7 @@ async def test_full_flow( assert "result" in result assert result["result"].unique_id == "600" assert "token" in result["result"].data + assert "webhook_id" in result["result"].data assert result["result"].data["token"]["access_token"] == "mock-access-token" assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" From c173ebd11ab7a5f71bfd9578a1510029cfda06d9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 15 Sep 2023 13:49:33 +0200 Subject: [PATCH 583/984] Add device_address to modbus configuration (#100399) --- homeassistant/components/modbus/__init__.py | 4 +++- homeassistant/components/modbus/base_platform.py | 3 ++- homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/validators.py | 4 +++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index a3c8928caaf..c228ba64459 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -62,6 +62,7 @@ from .const import ( # noqa: F401 CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -138,7 +139,8 @@ BASE_COMPONENT_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): cv.positive_int, - vol.Optional(CONF_SLAVE, default=0): cv.positive_int, + vol.Exclusive(CONF_DEVICE_ADDRESS, "slave_addr"): cv.positive_int, + vol.Exclusive(CONF_SLAVE, "slave_addr"): cv.positive_int, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index a3876bbe87c..739f234e8c4 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -42,6 +42,7 @@ from .const import ( CALL_TYPE_X_COILS, CALL_TYPE_X_REGISTER_HOLDINGS, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_VALUE, @@ -76,7 +77,7 @@ class BasePlatform(Entity): def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE, 0) + self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._value: str | None = None diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index e509577267c..7776cf96e70 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -17,6 +17,7 @@ CONF_BYTESIZE = "bytesize" CONF_CLIMATES = "climates" CONF_CLOSE_COMM_ON_ERROR = "close_comm_on_error" CONF_DATA_TYPE = "data_type" +CONF_DEVICE_ADDRESS = "device_address" CONF_FANS = "fans" CONF_INPUT_TYPE = "input_type" CONF_LAZY_ERROR = "lazy_error_count" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index aec781b065e..4297bf46cfe 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -25,6 +25,7 @@ from homeassistant.const import ( from .const import ( CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, @@ -241,7 +242,8 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_ON]) if CONF_COMMAND_OFF in entry: addr += "_" + str(entry[CONF_COMMAND_OFF]) - addr += "_" + str(entry.get(CONF_SLAVE, 0)) + 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" From ec2364ef439a9382cdf3dfe277d806f7a35fa257 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 15 Sep 2023 14:00:02 +0200 Subject: [PATCH 584/984] Add virtual_count == slave_count in modbus configuration (#100398) * Add virtual_count as config parameter. * Review (other PR) comments. * Review. * Review comment. --- homeassistant/components/modbus/__init__.py | 7 +++++-- homeassistant/components/modbus/base_platform.py | 5 ++++- homeassistant/components/modbus/binary_sensor.py | 11 +++++++++-- homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/sensor.py | 6 ++++-- homeassistant/components/modbus/validators.py | 7 ++++++- 6 files changed, 29 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index c228ba64459..5f3ddd7a4b5 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -106,6 +106,7 @@ from .const import ( # noqa: F401 CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_VERIFY, + CONF_VIRTUAL_COUNT, CONF_WRITE_REGISTERS, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, @@ -310,7 +311,8 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, + vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, + vol.Optional(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, vol.Optional(CONF_MIN_VALUE): number_validator, vol.Optional(CONF_MAX_VALUE): number_validator, vol.Optional(CONF_NAN_VALUE): nan_validator, @@ -330,7 +332,8 @@ BINARY_SENSOR_SCHEMA = BASE_COMPONENT_SCHEMA.extend( CALL_TYPE_REGISTER_INPUT, ] ), - vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, + vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_bin_count"): cv.positive_int, + vol.Exclusive(CONF_SLAVE_COUNT, "vir_bin_count"): cv.positive_int, } ) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 739f234e8c4..ee98b51b72a 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -59,6 +59,7 @@ from .const import ( CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, + CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, SIGNAL_START_ENTITY, @@ -166,7 +167,9 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): if self._scale < 1 and not self._precision: self._precision = 2 self._offset = config[CONF_OFFSET] - self._slave_count = config.get(CONF_SLAVE_COUNT, 0) + self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( + CONF_VIRTUAL_COUNT, 0 + ) self._slave_size = self._count = config[CONF_COUNT] def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 05668bac0a9..3dabeee081c 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -24,7 +24,12 @@ from homeassistant.helpers.update_coordinator import ( from . import get_hub from .base_platform import BasePlatform -from .const import CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_SLAVE_COUNT +from .const import ( + CALL_TYPE_COIL, + CALL_TYPE_DISCRETE, + CONF_SLAVE_COUNT, + CONF_VIRTUAL_COUNT, +) from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -46,7 +51,9 @@ async def async_setup_platform( sensors: list[ModbusBinarySensor | SlaveSensor] = [] hub = get_hub(hass, discovery_info[CONF_NAME]) for entry in discovery_info[CONF_BINARY_SENSORS]: - slave_count = entry.get(CONF_SLAVE_COUNT, 0) + slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( + CONF_VIRTUAL_COUNT, 0 + ) sensor = ModbusBinarySensor(hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 7776cf96e70..92a38bb5e92 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -62,6 +62,7 @@ CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" +CONF_VIRTUAL_COUNT = "virtual_count" CONF_WRITE_TYPE = "write_type" CONF_ZERO_SUPPRESS = "zero_suppress" diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index f2ed504b41b..d7a6b4cca0f 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.update_coordinator import ( from . import get_hub from .base_platform import BaseStructPlatform -from .const import CONF_SLAVE_COUNT +from .const import CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -50,7 +50,9 @@ async def async_setup_platform( sensors: list[ModbusRegisterSensor | SlaveSensor] = [] hub = get_hub(hass, discovery_info[CONF_NAME]) for entry in discovery_info[CONF_SENSORS]: - slave_count = entry.get(CONF_SLAVE_COUNT, 0) + slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( + CONF_VIRTUAL_COUNT, 0 + ) sensor = ModbusRegisterSensor(hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 4297bf46cfe..ca08ace853a 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -33,6 +33,7 @@ from .const import ( CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, @@ -98,6 +99,10 @@ 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, None) + slave_name = CONF_SLAVE_COUNT + if not slave_count: + slave_count = config.get(CONF_VIRTUAL_COUNT, 0) + slave_name = CONF_VIRTUAL_COUNT swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm if count and not validator.count: @@ -113,7 +118,7 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: error = f"{name}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) if slave_count and not validator.slave_count: - error = f"{name}: `{CONF_SLAVE_COUNT}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + error = f"{name}: `{slave_name}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) if swap_type != CONF_SWAP_NONE: swap_type_validator = { From 5ac149a7603a9c81fd9950b55b2ced0748fdd038 Mon Sep 17 00:00:00 2001 From: Seth <48533968+WillCodeForCats@users.noreply.github.com> Date: Fri, 15 Sep 2023 05:33:17 -0700 Subject: [PATCH 585/984] Remove state class from RainMachine TIMESTAMP sensors (#100400) --- homeassistant/components/rainmachine/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 6333dcc82f4..bdae62c1bd8 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -141,7 +141,6 @@ SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, data_key="lastLeakDetected", ), @@ -152,7 +151,6 @@ SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, data_key="rainSensorRainStart", ), From b4c095e944df7160867b0c91014407cd1f538746 Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Fri, 15 Sep 2023 14:42:27 +0200 Subject: [PATCH 586/984] Add missing timer service translation (#100388) --- homeassistant/components/timer/services.yaml | 2 ++ homeassistant/components/timer/strings.json | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index 74eeae22b23..ac5453d38c9 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -36,3 +36,5 @@ change: example: "00:01:00, 60 or -60" selector: text: + +reload: diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 56cb46d26b4..719cafe676a 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -62,6 +62,10 @@ "description": "Duration to add or subtract to the running timer." } } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." } } } From 9eb0b844bc67a4abd3593e0e70c6558c0390bf15 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 15 Sep 2023 15:02:24 +0200 Subject: [PATCH 587/984] Test VIRTUAL_COUNT parameter (#100434) --- tests/components/modbus/test_binary_sensor.py | 46 +++++++- tests/components/modbus/test_init.py | 19 +++ tests/components/modbus/test_sensor.py | 111 ++++++++++++++++-- 3 files changed, 161 insertions(+), 15 deletions(-) diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 1e413fcc764..7f668b26e04 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_SLAVE_COUNT, + CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, ) from homeassistant.const import ( @@ -265,7 +266,7 @@ ENTITY_ID2 = f"{ENTITY_ID}_1" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, } ] }, @@ -294,9 +295,18 @@ TEST_NAME = "test_sensor" } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_VIRTUAL_COUNT: 3, + } + ] + }, ], ) -async def test_config_slave_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: +async def test_config_virtual_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: """Run config test for binary sensor.""" assert SENSOR_DOMAIN in hass.config.components @@ -355,33 +365,63 @@ async def test_config_slave_binary_sensor(hass: HomeAssistant, mock_modbus) -> N STATE_OFF, [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [False] * 8, + STATE_OFF, + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True] + [False] * 7, STATE_ON, [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True] + [False] * 7, + STATE_ON, + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [False, True] + [False] * 6, STATE_OFF, [STATE_ON], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [False, True] + [False] * 6, + STATE_OFF, + [STATE_ON], + ), ( {CONF_SLAVE_COUNT: 7, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True, False] * 4, STATE_ON, [STATE_OFF, STATE_ON] * 3 + [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 7, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True, False] * 4, + STATE_ON, + [STATE_OFF, STATE_ON] * 3 + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 31, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True, False] * 16, STATE_ON, [STATE_OFF, STATE_ON] * 15 + [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 31, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True, False] * 16, + STATE_ON, + [STATE_OFF, STATE_ON] * 15 + [STATE_OFF], + ), ], ) -async def test_slave_binary_sensor( +async def test_virtual_binary_sensor( hass: HomeAssistant, expected, slaves, mock_do_cycle ) -> None: """Run test for given config.""" diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index c2f3e639580..a68ac2d3738 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -50,6 +50,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, RTUOVERTCP, @@ -263,11 +264,23 @@ async def test_ok_struct_validator(do_config) -> None: CONF_STRUCTURE: ">f", CONF_SLAVE_COUNT: 5, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_COUNT: 2, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">f", + CONF_VIRTUAL_COUNT: 5, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.STRING, CONF_SLAVE_COUNT: 2, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_DATA_TYPE: DataType.STRING, + CONF_VIRTUAL_COUNT: 2, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.INT16, @@ -279,6 +292,12 @@ async def test_ok_struct_validator(do_config) -> None: CONF_SLAVE_COUNT: 2, CONF_DATA_TYPE: DataType.INT32, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_COUNT: 2, + CONF_VIRTUAL_COUNT: 2, + CONF_DATA_TYPE: DataType.INT32, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.INT16, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 14bccbafac4..0833c0e2f7f 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -21,6 +21,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, CONF_ZERO_SUPPRESS, MODBUS_DOMAIN, DataType, @@ -150,6 +151,16 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DATA_TYPE: DataType.INT32, + CONF_VIRTUAL_COUNT: 5, + } + ] + }, ], ) async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: @@ -671,6 +682,21 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["34899771392", "0"], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + 0x5102, + 0x0304, + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + False, + ["34899771392", "0"], + ), ( { CONF_SLAVE_COUNT: 0, @@ -680,6 +706,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["16909060"], ), + ( + { + CONF_VIRTUAL_COUNT: 0, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304], + False, + ["16909060"], + ), ( { CONF_SLAVE_COUNT: 1, @@ -689,6 +724,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["16909060", "67305985"], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201], + False, + ["16909060", "67305985"], + ), ( { CONF_SLAVE_COUNT: 3, @@ -712,6 +756,29 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: "219025152", ], ), + ( + { + CONF_VIRTUAL_COUNT: 3, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [ + 0x0102, + 0x0304, + 0x0506, + 0x0708, + 0x090A, + 0x0B0C, + 0x0D0E, + 0x0F00, + ], + False, + [ + "16909060", + "84281096", + "151653132", + "219025152", + ], + ), ( { CONF_SLAVE_COUNT: 1, @@ -721,6 +788,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: True, [STATE_UNAVAILABLE, STATE_UNKNOWN], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201], + True, + [STATE_UNAVAILABLE, STATE_UNKNOWN], + ), ( { CONF_SLAVE_COUNT: 1, @@ -730,9 +806,18 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, [STATE_UNAVAILABLE, STATE_UNKNOWN], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [], + False, + [STATE_UNAVAILABLE, STATE_UNKNOWN], + ), ], ) -async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: +async def test_virtual_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: """Run test for sensor.""" entity_registry = er.async_get(hass) for i in range(0, len(expected)): @@ -766,7 +851,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non [ ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT16, @@ -777,7 +862,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT32, @@ -788,7 +873,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT64, @@ -799,7 +884,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT16, CONF_SWAP: CONF_SWAP_BYTE, @@ -810,7 +895,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT32, CONF_SWAP: CONF_SWAP_WORD, @@ -821,7 +906,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT64, CONF_SWAP: CONF_SWAP_WORD, @@ -832,7 +917,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT16, CONF_SWAP: CONF_SWAP_BYTE, @@ -843,7 +928,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT32, CONF_SWAP: CONF_SWAP_WORD, @@ -868,7 +953,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT64, CONF_SWAP: CONF_SWAP_WORD, @@ -901,7 +986,9 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ], ) -async def test_slave_swap_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: +async def test_virtual_swap_sensor( + hass: HomeAssistant, mock_do_cycle, expected +) -> None: """Run test for sensor.""" for i in range(0, len(expected)): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -1230,7 +1317,7 @@ async def mock_restore(hass): CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, } ] }, From b329439fff23814d53c4966e535b1b6343bb4d53 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 15 Sep 2023 16:05:56 +0200 Subject: [PATCH 588/984] Fix timer reload description (#100433) Fix copy/paste error of #100388 --- homeassistant/components/timer/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 719cafe676a..1ebf0c6f50a 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -65,7 +65,7 @@ }, "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads helpers from the YAML-configuration." + "description": "Reloads timers from the YAML-configuration." } } } From fd83f7d87f991185ffa8349976f2df5752f1afbd Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 15 Sep 2023 16:12:44 +0200 Subject: [PATCH 589/984] Add test for modbus CONF_DEVICE_ADDR (#100435) --- homeassistant/components/modbus/__init__.py | 2 +- tests/components/modbus/conftest.py | 5 +--- tests/components/modbus/test_binary_sensor.py | 25 ++++++++++++++++++- tests/components/modbus/test_climate.py | 11 ++++++++ tests/components/modbus/test_cover.py | 13 ++++++++++ tests/components/modbus/test_fan.py | 19 ++++++++++++++ tests/components/modbus/test_init.py | 15 +++++++++++ tests/components/modbus/test_light.py | 18 +++++++++++++ tests/components/modbus/test_sensor.py | 18 +++++++++++++ tests/components/modbus/test_switch.py | 19 ++++++++++++++ 10 files changed, 139 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 5f3ddd7a4b5..875669e6dd7 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -312,7 +312,7 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, - vol.Optional(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, + vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, vol.Optional(CONF_MIN_VALUE): number_validator, vol.Optional(CONF_MAX_VALUE): number_validator, vol.Optional(CONF_NAN_VALUE): nan_validator, diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index d4c7dfa5e10..a08743b7e6c 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -10,7 +10,7 @@ from pymodbus.exceptions import ModbusException import pytest from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SLAVE, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -87,9 +87,6 @@ async def mock_modbus_fixture( for key in conf: if config_addon: conf[key][0].update(config_addon) - for entity in conf[key]: - if CONF_SLAVE not in entity: - entity[CONF_SLAVE] = 0 caplog.set_level(logging.WARNING) config = { DOMAIN: [ diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 7f668b26e04..2069aa23b8f 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_SLAVE_COUNT, @@ -60,6 +61,18 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, + CONF_DEVICE_CLASS: "door", + CONF_LAZY_ERROR: 10, + } + ] + }, { CONF_BINARY_SENSORS: [ { @@ -70,6 +83,16 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + } + ] + }, ], ) async def test_config_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: @@ -299,7 +322,7 @@ TEST_NAME = "test_sensor" CONF_BINARY_SENSORS: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, + CONF_ADDRESS: 52, CONF_VIRTUAL_COUNT: 3, } ] diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 4ab78df0c81..f2de0177c74 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -11,6 +11,7 @@ from homeassistant.components.climate.const import ( from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -57,6 +58,16 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_DEVICE_ADDRESS: 10, + } + ], + }, { CONF_CLIMATES: [ { diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 66e4537d67e..b91b38b1f70 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -7,6 +7,7 @@ from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_CLOSED, @@ -62,6 +63,18 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" } ] }, + { + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_ADDRESS: 10, + CONF_SCAN_INTERVAL: 20, + CONF_LAZY_ERROR: 10, + } + ] + }, ], ) async def test_config_cover(hass: HomeAssistant, mock_modbus) -> None: diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 2d2cc83162d..e47ed5c2371 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, @@ -75,6 +76,24 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" } ] }, + { + CONF_FANS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + 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, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_FANS: [ { diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index a68ac2d3738..f9c7fb42b2d 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -41,6 +41,7 @@ from homeassistant.components.modbus.const import ( CONF_BAUDRATE, CONF_BYTESIZE, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -517,6 +518,20 @@ async def test_duplicate_entity_validator(do_config) -> None: } ], }, + { + # Special test for scan_interval validator with scan_interval: 0 + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_DEVICE_ADDRESS: 0, + CONF_SCAN_INTERVAL: 0, + } + ], + }, ], ) async def test_config_modbus( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 46763b3b3a2..a5871bdbd67 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_OFF, @@ -75,6 +76,23 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" } ] }, + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + CONF_VERIFY: { + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1235, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_LIGHTS: [ { diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 0833c0e2f7f..46c38873a93 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_VALUE, @@ -86,6 +87,23 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_DATA_TYPE: DataType.INT16, + CONF_PRECISION: 0, + 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", + } + ] + }, { CONF_SENSORS: [ { diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 7a79e19869a..ff7d6860f3b 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -11,6 +11,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_OFF, @@ -85,6 +86,24 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" } ] }, + { + CONF_SWITCHES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + CONF_DEVICE_CLASS: "switch", + CONF_VERIFY: { + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1235, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_SWITCHES: [ { From 06949b181f9b487f206e2a32a69438a35898ea40 Mon Sep 17 00:00:00 2001 From: Matrix Date: Fri, 15 Sep 2023 23:20:30 +0800 Subject: [PATCH 590/984] Bump yolink-api to 0.3.1 (#100426) --- 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 ced0d527c7d..7322c58ae04 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.0"] + "requirements": ["yolink-api==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0304faa1f08..b0bd80c177f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2751,7 +2751,7 @@ yeelight==0.7.13 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.0 +yolink-api==0.3.1 # homeassistant.components.youless youless-api==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6063842f5fc..f085364261f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2036,7 +2036,7 @@ yalexs==1.9.0 yeelight==0.7.13 # homeassistant.components.yolink -yolink-api==0.3.0 +yolink-api==0.3.1 # homeassistant.components.youless youless-api==1.0.1 From c9975852bb3b44d7a52a1a56130ee724b2bf2716 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 15 Sep 2023 19:03:04 +0200 Subject: [PATCH 591/984] bump pywaze to 0.5.0 (#100456) --- homeassistant/components/waze_travel_time/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index c72d9b1dbad..1a4be798367 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", "iot_class": "cloud_polling", "loggers": ["pywaze", "homeassistant.helpers.location"], - "requirements": ["pywaze==0.4.0"] + "requirements": ["pywaze==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0bd80c177f..1eaae711a29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2235,7 +2235,7 @@ pyvlx==0.2.20 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.4.0 +pywaze==0.5.0 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f085364261f..232840ae917 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1649,7 +1649,7 @@ pyvizio==0.1.61 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.4.0 +pywaze==0.5.0 # homeassistant.components.html5 pywebpush==1.9.2 From a4e0444b95231e312bc22263df72ba1c8026910d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Sep 2023 13:38:14 -0500 Subject: [PATCH 592/984] Bump sense-energy to 0.12.2 (#100459) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 6 ++---- requirements_test_all.txt | 6 ++---- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index d39d530eccc..843aeddde7b 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.12.1"] + "requirements": ["sense-energy==0.12.2"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 8a89d6d8531..7ef1caefe48 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.12.1"] + "requirements": ["sense-energy==0.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1eaae711a29..a87177e296d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2372,11 +2372,9 @@ securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 -# homeassistant.components.sense -sense-energy==0.12.1 - # homeassistant.components.emulated_kasa -sense_energy==0.12.1 +# homeassistant.components.sense +sense-energy==0.12.2 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 232840ae917..c2d44de0327 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1741,11 +1741,9 @@ screenlogicpy==0.9.0 # homeassistant.components.backup securetar==2023.3.0 -# homeassistant.components.sense -sense-energy==0.12.1 - # homeassistant.components.emulated_kasa -sense_energy==0.12.1 +# homeassistant.components.sense +sense-energy==0.12.2 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From a111988232330a30e890429f208196bf52f7c3d5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Sep 2023 20:39:14 +0200 Subject: [PATCH 593/984] Make codespell ignore snapshots (#100463) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5fafdd6dab..b0c98143300 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 exclude_types: [csv, json] - exclude: ^tests/fixtures/|homeassistant/generated/ + exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: From d25f45a957327a94489f697c96b2425b2d5def55 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 16 Sep 2023 09:57:55 +0200 Subject: [PATCH 594/984] Harden modbus against lib errors (#100469) --- homeassistant/components/modbus/modbus.py | 6 ++++++ tests/components/modbus/conftest.py | 9 +++++++++ tests/components/modbus/test_fan.py | 5 ++++- tests/components/modbus/test_light.py | 5 ++++- tests/components/modbus/test_switch.py | 9 +++++++-- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index a503b71593c..31179a23583 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -419,9 +419,15 @@ class ModbusHub: except ModbusException as exception_error: self._log_error(str(exception_error)) return None + if not result: + self._log_error("Error: pymodbus returned None") + return None if not hasattr(result, entry.attr): self._log_error(str(result)) return None + if result.isError(): # type: ignore[no-untyped-call] + self._log_error("Error: pymodbus returned isError True") + return None self._in_error = False return result diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index a08743b7e6c..460b1eb5dd3 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -32,6 +32,11 @@ class ReadResult: """Init.""" self.registers = register_words self.bits = register_words + self.value = register_words + + def isError(self): + """Set error state.""" + return False @pytest.fixture(name="mock_pymodbus") @@ -136,6 +141,10 @@ async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): mock_modbus.read_discrete_inputs.return_value = read_result mock_modbus.read_input_registers.return_value = read_result mock_modbus.read_holding_registers.return_value = read_result + mock_modbus.write_register.return_value = read_result + mock_modbus.write_registers.return_value = read_result + mock_modbus.write_coil.return_value = read_result + mock_modbus.write_coils.return_value = read_result @pytest.fixture(name="mock_do_cycle") diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index e47ed5c2371..932e07b2d1a 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -261,7 +261,10 @@ async def test_restore_state_fan( ], ) async def test_fan_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index a5871bdbd67..1d6963aaa12 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -260,7 +260,10 @@ async def test_restore_state_light( ], ) async def test_light_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index ff7d6860f3b..0eb40d2c082 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -316,7 +316,10 @@ async def test_restore_state_switch( ], ) async def test_switch_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" assert MODBUS_DOMAIN in hass.config.components @@ -407,7 +410,9 @@ async def test_service_switch_update(hass: HomeAssistant, mock_modbus, mock_ha) }, ], ) -async def test_delay_switch(hass: HomeAssistant, mock_modbus) -> None: +async def test_delay_switch( + hass: HomeAssistant, mock_modbus, mock_pymodbus_return +) -> None: """Run test for switch verify delay.""" mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) now = dt_util.utcnow() From 9747e0091f99a92e8a9c6c9d79b1318ab8ddd7d6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 16 Sep 2023 10:13:27 +0200 Subject: [PATCH 595/984] Use shorthand attrs for device_class zwave_js sensor (#100414) * Use shorthand attrs zwave_js sensor * Simplify --- homeassistant/components/zwave_js/sensor.py | 32 ++------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3c22288a1d6..8d42bcfb366 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -17,7 +17,7 @@ from zwave_js_server.model.controller.statistics import ControllerStatisticsData from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node.statistics import NodeStatisticsDataType -from zwave_js_server.model.value import ConfigurationValue, ConfigurationValueType +from zwave_js_server.model.value import ConfigurationValue from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( @@ -729,22 +729,9 @@ class ZWaveListSensor(ZwaveSensor): alternate_value_name=self.info.primary_value.property_name, additional_info=[self.info.primary_value.property_key_name], ) - - @property - def options(self) -> list[str] | None: - """Return options for enum sensor.""" - if self.device_class == SensorDeviceClass.ENUM: - return list(self.info.primary_value.metadata.states.values()) - return None - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return sensor device class.""" - if (device_class := super().device_class) is not None: - return device_class if self.info.primary_value.metadata.states: - return SensorDeviceClass.ENUM - return None + self._attr_device_class = SensorDeviceClass.ENUM + self._attr_options = list(info.primary_value.metadata.states.values()) @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -781,19 +768,6 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): additional_info=[property_key_name] if property_key_name else None, ) - @property - def device_class(self) -> SensorDeviceClass | None: - """Return sensor device class.""" - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - if (device_class := ZwaveSensor.device_class.fget(self)) is not None: # type: ignore[attr-defined] - return device_class # type: ignore[no-any-return] - if ( - self._primary_value.configuration_value_type - == ConfigurationValueType.ENUMERATED - ): - return SensorDeviceClass.ENUM - return None - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" From c504ca906dd5f0cc35a532b5679b9bb5c5304a18 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:18:19 +0200 Subject: [PATCH 596/984] Move co2signal exceptions to their own file (#100473) * Move co2signal exceptions to their own file * Add myself as codeowner --- CODEOWNERS | 2 ++ .../components/co2signal/__init__.py | 19 ++----------------- .../components/co2signal/config_flow.py | 3 ++- .../components/co2signal/exceptions.py | 18 ++++++++++++++++++ .../components/co2signal/manifest.json | 2 +- 5 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/co2signal/exceptions.py diff --git a/CODEOWNERS b/CODEOWNERS index 7463731e57a..7c96042caa3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -205,6 +205,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/coinbase/ @tombrien /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 721a26e147f..943fa13e240 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -11,10 +11,11 @@ import CO2Signal from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_COUNTRY_CODE, DOMAIN +from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -86,22 +87,6 @@ class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): return data -class CO2Error(HomeAssistantError): - """Base error.""" - - -class InvalidAuth(CO2Error): - """Raised when invalid authentication credentials are provided.""" - - -class APIRatelimitExceeded(CO2Error): - """Raised when the API rate limit is exceeded.""" - - -class UnknownError(CO2Error): - """Raised when an unknown error occurs.""" - - def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: """Get data from the API.""" if CONF_COUNTRY_CODE in config: diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 036282cb3e8..2ac3ebc398f 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,8 +10,9 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import APIRatelimitExceeded, InvalidAuth, get_data +from . import get_data from .const import CONF_COUNTRY_CODE, DOMAIN +from .exceptions import APIRatelimitExceeded, InvalidAuth from .util import get_extra_name TYPE_USE_HOME = "Use home location" diff --git a/homeassistant/components/co2signal/exceptions.py b/homeassistant/components/co2signal/exceptions.py new file mode 100644 index 00000000000..cc8ee709bde --- /dev/null +++ b/homeassistant/components/co2signal/exceptions.py @@ -0,0 +1,18 @@ +"""Exceptions to the co2signal integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class CO2Error(HomeAssistantError): + """Base error.""" + + +class InvalidAuth(CO2Error): + """Raised when invalid authentication credentials are provided.""" + + +class APIRatelimitExceeded(CO2Error): + """Raised when the API rate limit is exceeded.""" + + +class UnknownError(CO2Error): + """Raised when an unknown error occurs.""" diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index a0a3ee71a9c..4ab4607cccc 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,7 +1,7 @@ { "domain": "co2signal", "name": "Electricity Maps", - "codeowners": [], + "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", "iot_class": "cloud_polling", From 024db6dadfc0a0036a6de38d1ae8be78bd4603eb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:19:05 +0200 Subject: [PATCH 597/984] Move cert_expiry coordinator to its own file (#100472) * Move cert_expiry coordinator to its own file * Add missing patched config flow test --- .../components/cert_expiry/__init__.py | 47 +---------------- .../components/cert_expiry/coordinator.py | 51 +++++++++++++++++++ .../cert_expiry/test_config_flow.py | 8 +-- tests/components/cert_expiry/test_init.py | 8 +-- tests/components/cert_expiry/test_sensors.py | 10 ++-- 5 files changed, 66 insertions(+), 58 deletions(-) create mode 100644 homeassistant/components/cert_expiry/coordinator.py diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 5d1e68a951f..391bb3ef8f3 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1,22 +1,13 @@ """The cert_expiry component.""" from __future__ import annotations -from datetime import datetime, timedelta -import logging - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_PORT, DOMAIN -from .errors import TemporaryFailure, ValidationFailure -from .helper import get_cert_expiry_timestamp - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(hours=12) +from .const import DOMAIN +from .coordinator import CertExpiryDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -45,37 +36,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): - """Class to manage fetching Cert Expiry data from single endpoint.""" - - def __init__(self, hass, host, port): - """Initialize global Cert Expiry data updater.""" - self.host = host - self.port = port - self.cert_error = None - self.is_cert_valid = False - - display_port = f":{port}" if port != DEFAULT_PORT else "" - name = f"{self.host}{display_port}" - - super().__init__( - hass, _LOGGER, name=name, update_interval=SCAN_INTERVAL, always_update=False - ) - - async def _async_update_data(self) -> datetime | None: - """Fetch certificate.""" - try: - timestamp = await get_cert_expiry_timestamp(self.hass, self.host, self.port) - except TemporaryFailure as err: - raise UpdateFailed(err.args[0]) from err - except ValidationFailure as err: - self.cert_error = err - self.is_cert_valid = False - _LOGGER.error("Certificate validation error: %s [%s]", self.host, err) - return None - - self.cert_error = None - self.is_cert_valid = True - return timestamp diff --git a/homeassistant/components/cert_expiry/coordinator.py b/homeassistant/components/cert_expiry/coordinator.py new file mode 100644 index 00000000000..6a125758f70 --- /dev/null +++ b/homeassistant/components/cert_expiry/coordinator.py @@ -0,0 +1,51 @@ +"""DataUpdateCoordinator for cert_expiry coordinator.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_PORT +from .errors import TemporaryFailure, ValidationFailure +from .helper import get_cert_expiry_timestamp + +_LOGGER = logging.getLogger(__name__) + + +class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): + """Class to manage fetching Cert Expiry data from single endpoint.""" + + def __init__(self, hass, host, port): + """Initialize global Cert Expiry data updater.""" + self.host = host + self.port = port + self.cert_error = None + self.is_cert_valid = False + + display_port = f":{port}" if port != DEFAULT_PORT else "" + name = f"{self.host}{display_port}" + + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=timedelta(hours=12), + always_update=False, + ) + + async def _async_update_data(self) -> datetime | None: + """Fetch certificate.""" + try: + timestamp = await get_cert_expiry_timestamp(self.hass, self.host, self.port) + except TemporaryFailure as err: + raise UpdateFailed(err.args[0]) from err + except ValidationFailure as err: + self.cert_error = err + self.is_cert_valid = False + _LOGGER.error("Certificate validation error: %s [%s]", self.host, err) + return None + + self.cert_error = None + self.is_cert_valid = True + return timestamp diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index 52985da0014..f950fce6a68 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -67,7 +67,7 @@ async def test_import_host_only(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -89,7 +89,7 @@ async def test_import_host_and_port(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -111,7 +111,7 @@ async def test_import_non_default_port(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -133,7 +133,7 @@ async def test_import_with_name(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 29fbf372ec4..6c1d593560e 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -42,7 +42,7 @@ async def test_setup_with_config(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): await hass.async_block_till_done() @@ -63,7 +63,7 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert not entry.unique_id with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): assert await async_setup_component(hass, DOMAIN, {}) is True @@ -91,7 +91,7 @@ async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): assert await async_setup_component(hass, DOMAIN, {}) is True @@ -134,7 +134,7 @@ async def test_delay_load_during_startup(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): await hass.async_start() diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index e6a526c7c9e..48421f5c41f 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -29,7 +29,7 @@ async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -83,7 +83,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -99,7 +99,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=24)) @@ -127,7 +127,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -156,7 +156,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=48)) From 57337b5cee5a5ab076ab89ded23219b40497e6b8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:19:49 +0200 Subject: [PATCH 598/984] Move flipr coordinator to its own file (#100467) --- homeassistant/components/flipr/__init__.py | 47 +------------------ homeassistant/components/flipr/coordinator.py | 45 ++++++++++++++++++ tests/components/flipr/test_init.py | 2 +- 3 files changed, 48 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/flipr/coordinator.py diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 81c21a4aa99..865aeaa2d28 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,27 +1,16 @@ """The Flipr integration.""" -from datetime import timedelta -import logging - -from flipr_api import FliprAPIRestClient -from flipr_api.exceptions import FliprError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, - UpdateFailed, ) from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(minutes=60) - +from .coordinator import FliprDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -49,38 +38,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class FliprDataUpdateCoordinator(DataUpdateCoordinator): - """Class to hold Flipr data retrieval.""" - - def __init__(self, hass, entry): - """Initialize.""" - username = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - self.flipr_id = entry.data[CONF_FLIPR_ID] - - # Establishes the connection. - self.client = FliprAPIRestClient(username, password) - self.entry = entry - - super().__init__( - hass, - _LOGGER, - name=f"Flipr data measure for {self.flipr_id}", - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from API endpoint.""" - try: - data = await self.hass.async_add_executor_job( - self.client.get_pool_measure_latest, self.flipr_id - ) - except FliprError as error: - raise UpdateFailed(error) from error - - return data - - class FliprEntity(CoordinatorEntity): """Implements a common class elements representing the Flipr component.""" diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py new file mode 100644 index 00000000000..d51db645035 --- /dev/null +++ b/homeassistant/components/flipr/coordinator.py @@ -0,0 +1,45 @@ +"""DataUpdateCoordinator for flipr integration.""" +from datetime import timedelta +import logging + +from flipr_api import FliprAPIRestClient +from flipr_api.exceptions import FliprError + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_FLIPR_ID + +_LOGGER = logging.getLogger(__name__) + + +class FliprDataUpdateCoordinator(DataUpdateCoordinator): + """Class to hold Flipr data retrieval.""" + + def __init__(self, hass, entry): + """Initialize.""" + username = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + self.flipr_id = entry.data[CONF_FLIPR_ID] + + # Establishes the connection. + self.client = FliprAPIRestClient(username, password) + self.entry = entry + + super().__init__( + hass, + _LOGGER, + name=f"Flipr data measure for {self.flipr_id}", + update_interval=timedelta(minutes=60), + ) + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + try: + data = await self.hass.async_add_executor_job( + self.client.get_pool_measure_latest, self.flipr_id + ) + except FliprError as error: + raise UpdateFailed(error) from error + + return data diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py index e9685bd6e0a..c1c5c0086e7 100644 --- a/tests/components/flipr/test_init.py +++ b/tests/components/flipr/test_init.py @@ -21,7 +21,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: unique_id="123456", ) entry.add_to_hass(hass) - with patch("homeassistant.components.flipr.FliprAPIRestClient"): + with patch("homeassistant.components.flipr.coordinator.FliprAPIRestClient"): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() await hass.config_entries.async_unload(entry.entry_id) From b5c6e8237471e9734ce4244986055035715c6cac Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:49:49 +0200 Subject: [PATCH 599/984] Move co2signal models to their own file (#100478) --- .../components/co2signal/__init__.py | 25 ++----------------- homeassistant/components/co2signal/models.py | 24 ++++++++++++++++++ 2 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/co2signal/models.py diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 943fa13e240..79c56ec63d4 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any, TypedDict, cast +from typing import Any, cast import CO2Signal @@ -16,33 +16,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_COUNTRY_CODE, DOMAIN from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError +from .models import CO2SignalResponse PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -class CO2SignalData(TypedDict): - """Data field.""" - - carbonIntensity: float - fossilFuelPercentage: float - - -class CO2SignalUnit(TypedDict): - """Unit field.""" - - carbonIntensity: str - - -class CO2SignalResponse(TypedDict): - """API response.""" - - status: str - countryCode: str - data: CO2SignalData - units: CO2SignalUnit - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up CO2 Signal from a config entry.""" coordinator = CO2SignalCoordinator(hass, entry) diff --git a/homeassistant/components/co2signal/models.py b/homeassistant/components/co2signal/models.py new file mode 100644 index 00000000000..758bb15c5f0 --- /dev/null +++ b/homeassistant/components/co2signal/models.py @@ -0,0 +1,24 @@ +"""Models to the co2signal integration.""" +from typing import TypedDict + + +class CO2SignalData(TypedDict): + """Data field.""" + + carbonIntensity: float + fossilFuelPercentage: float + + +class CO2SignalUnit(TypedDict): + """Unit field.""" + + carbonIntensity: str + + +class CO2SignalResponse(TypedDict): + """API response.""" + + status: str + countryCode: str + data: CO2SignalData + units: CO2SignalUnit From 16cc87bf45de5a39859d39f7744594602c16a6f8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:55:49 +0200 Subject: [PATCH 600/984] Move flipr base entity to its own file (#100481) * Move flipr base entity to its own file * Add forgotten __init__.py --- homeassistant/components/flipr/__init__.py | 31 +----------------- .../components/flipr/binary_sensor.py | 2 +- homeassistant/components/flipr/entity.py | 32 +++++++++++++++++++ homeassistant/components/flipr/sensor.py | 2 +- 4 files changed, 35 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/flipr/entity.py diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 865aeaa2d28..e6d7cb1dd17 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -2,14 +2,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) -from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER +from .const import DOMAIN from .coordinator import FliprDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -36,26 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class FliprEntity(CoordinatorEntity): - """Implements a common class elements representing the Flipr component.""" - - _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True - - def __init__( - self, coordinator: DataUpdateCoordinator, description: EntityDescription - ) -> None: - """Initialize Flipr sensor.""" - super().__init__(coordinator) - self.entity_description = description - if coordinator.config_entry: - flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] - self._attr_unique_id = f"{flipr_id}-{description.key}" - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, flipr_id)}, - manufacturer=MANUFACTURER, - name=f"Flipr {flipr_id}", - ) diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index 0597145c2da..677a282e8cb 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprEntity from .const import DOMAIN +from .entity import FliprEntity BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( diff --git a/homeassistant/components/flipr/entity.py b/homeassistant/components/flipr/entity.py new file mode 100644 index 00000000000..6166d727ac7 --- /dev/null +++ b/homeassistant/components/flipr/entity.py @@ -0,0 +1,32 @@ +"""Base entity for the flipr entity.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER + + +class FliprEntity(CoordinatorEntity): + """Implements a common class elements representing the Flipr component.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize Flipr sensor.""" + super().__init__(coordinator) + self.entity_description = description + if coordinator.config_entry: + flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] + self._attr_unique_id = f"{flipr_id}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, flipr_id)}, + manufacturer=MANUFACTURER, + name=f"Flipr {flipr_id}", + ) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 078e581edda..a8618b2df87 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -12,8 +12,8 @@ from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTempe from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprEntity from .const import DOMAIN +from .entity import FliprEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( From 30d604c8510bae333088155122621aa28b6af32e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 16 Sep 2023 13:46:11 +0200 Subject: [PATCH 601/984] Use central logger in Withings (#100406) --- homeassistant/components/withings/__init__.py | 9 ++++--- homeassistant/components/withings/api.py | 8 +++---- homeassistant/components/withings/common.py | 24 +++++++++---------- homeassistant/components/withings/const.py | 3 +++ 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 589bfe79094..5e733708639 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -34,13 +34,12 @@ from homeassistant.helpers.typing import ConfigType from . import const from .common import ( - _LOGGER, async_get_data_manager, async_remove_data_manager, get_data_manager_by_webhook_id, json_message_response, ) -from .const import CONF_USE_WEBHOOK, CONFIG +from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -92,7 +91,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf[CONF_CLIENT_SECRET], ), ) - _LOGGER.warning( + LOGGER.warning( "Configuration of Withings integration OAuth2 credentials in YAML " "is deprecated and will be removed in a future release; Your " "existing OAuth Application Credentials have been imported into " @@ -125,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data_manager = await async_get_data_manager(hass, entry) - _LOGGER.debug("Confirming %s is authenticated to withings", entry.title) + LOGGER.debug("Confirming %s is authenticated to withings", entry.title) await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() webhook.async_register( @@ -205,7 +204,7 @@ async def async_webhook_handler( data_manager = get_data_manager_by_webhook_id(hass, webhook_id) if not data_manager: - _LOGGER.error( + LOGGER.error( ( "Webhook id %s not handled by data manager. This is a bug and should be" " reported" diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py index fff9767ebda..3a81fb298ea 100644 --- a/homeassistant/components/withings/api.py +++ b/homeassistant/components/withings/api.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Iterable -import logging from typing import Any import arrow @@ -26,9 +25,8 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, ) -from .const import LOG_NAMESPACE +from .const import LOGGER -_LOGGER = logging.getLogger(LOG_NAMESPACE) _RETRY_COEFFICIENT = 0.5 @@ -73,11 +71,11 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): """ exception = None for attempt in range(1, attempts + 1): - _LOGGER.debug("Attempt %s of %s", attempt, attempts) + LOGGER.debug("Attempt %s of %s", attempt, attempts) try: return await func() except Exception as exception1: # pylint: disable=broad-except - _LOGGER.debug( + LOGGER.debug( "Failed attempt %s of %s (%s)", attempt, attempts, exception1 ) # Make each backoff pause a little bit longer diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 446fb4b58e5..5f0090ad9a6 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -8,7 +8,6 @@ import datetime from datetime import timedelta from enum import IntEnum, StrEnum from http import HTTPStatus -import logging import re from typing import Any @@ -35,9 +34,8 @@ from homeassistant.util import dt as dt_util from . import const from .api import ConfigEntryWithingsApi -from .const import Measurement +from .const import LOGGER, Measurement -_LOGGER = logging.getLogger(const.LOG_NAMESPACE) NOT_AUTHENTICATED_ERROR = re.compile( f"^{HTTPStatus.UNAUTHORIZED},.*", re.IGNORECASE, @@ -181,7 +179,7 @@ class DataManager: self.subscription_update_coordinator = DataUpdateCoordinator( hass, - _LOGGER, + LOGGER, name="subscription_update_coordinator", update_interval=timedelta(minutes=120), update_method=self.async_subscribe_webhook, @@ -190,7 +188,7 @@ class DataManager: dict[MeasureType, Any] | None ]( hass, - _LOGGER, + LOGGER, name="poll_data_update_coordinator", update_interval=timedelta(minutes=120) if self._webhook_config.enabled @@ -232,14 +230,14 @@ class DataManager: async def async_subscribe_webhook(self) -> None: """Subscribe the webhook to withings data updates.""" - _LOGGER.debug("Configuring withings webhook") + LOGGER.debug("Configuring withings webhook") # On first startup, perform a fresh re-subscribe. Withings stops pushing data # if the webhook fails enough times but they don't remove the old subscription # config. This ensures the subscription is setup correctly and they start # pushing again. if self._subscribe_webhook_run_count == 0: - _LOGGER.debug("Refreshing withings webhook configs") + LOGGER.debug("Refreshing withings webhook configs") await self.async_unsubscribe_webhook() self._subscribe_webhook_run_count += 1 @@ -262,7 +260,7 @@ class DataManager: # Subscribe to each one. for appli in to_add_applis: - _LOGGER.debug( + LOGGER.debug( "Subscribing %s for %s in %s seconds", self._webhook_config.url, appli, @@ -280,7 +278,7 @@ class DataManager: # Revoke subscriptions. for profile in response.profiles: - _LOGGER.debug( + LOGGER.debug( "Unsubscribing %s for %s in %s seconds", profile.callbackurl, profile.appli, @@ -310,7 +308,7 @@ class DataManager: async def async_get_measures(self) -> dict[Measurement, Any]: """Get the measures data.""" - _LOGGER.debug("Updating withings measures") + LOGGER.debug("Updating withings measures") now = dt_util.utcnow() startdate = now - datetime.timedelta(days=7) @@ -338,7 +336,7 @@ class DataManager: async def async_get_sleep_summary(self) -> dict[Measurement, Any]: """Get the sleep summary data.""" - _LOGGER.debug("Updating withing sleep summary") + LOGGER.debug("Updating withing sleep summary") now = dt_util.now() yesterday = now - datetime.timedelta(days=1) yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta( @@ -419,7 +417,7 @@ class DataManager: async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: """Handle scenario when data is updated from a webook.""" - _LOGGER.debug("Withings webhook triggered") + LOGGER.debug("Withings webhook triggered") if data_category in { NotifyAppli.WEIGHT, NotifyAppli.CIRCULATORY, @@ -442,7 +440,7 @@ async def async_get_data_manager( config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] if const.DATA_MANAGER not in config_entry_data: - _LOGGER.debug( + LOGGER.debug( "Creating withings data manager for profile: %s", config_entry.title ) config_entry_data[const.DATA_MANAGER] = DataManager( diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 926d29abe5c..545c7bfcb26 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,5 +1,6 @@ """Constants used by the Withings component.""" from enum import StrEnum +import logging DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" @@ -13,6 +14,8 @@ LOG_NAMESPACE = "homeassistant.components.withings" PROFILE = "profile" PUSH_HANDLER = "push_handler" +LOGGER = logging.getLogger(__package__) + class Measurement(StrEnum): """Measurement supported by the withings integration.""" From 48dc81eff09c46780d5f57c76bd0c9d2bcfe9f49 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 16 Sep 2023 13:49:37 +0200 Subject: [PATCH 602/984] Simplify code, due to better error catching in modbus. (#100483) --- homeassistant/components/modbus/binary_sensor.py | 5 +---- homeassistant/components/modbus/climate.py | 5 ----- homeassistant/components/modbus/cover.py | 5 ----- homeassistant/components/modbus/modbus.py | 2 +- 4 files changed, 2 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 3dabeee081c..39174ae8931 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -122,10 +122,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): self._result = result.bits else: self._result = result.registers - if len(self._result) >= 1: - self._attr_is_on = bool(self._result[0] & 1) - else: - self._attr_available = False + self._attr_is_on = bool(self._result[0] & 1) self.async_write_ha_state() if self._coordinator: diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 3acf8d7ac29..df2983e9070 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -247,10 +247,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval - # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True self._attr_target_temperature = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register ) @@ -282,7 +278,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if onoff == 0: self._attr_hvac_mode = HVACMode.OFF - self._call_active = False self.async_write_ha_state() async def _async_read_register( diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 3c4247c61fb..27f9cb1fc18 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -138,14 +138,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval - # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._address, 1, self._input_type ) - self._call_active = False if result is None: if self._lazy_errors: self._lazy_errors -= 1 diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 31179a23583..db8a4d47fdc 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -261,7 +261,7 @@ class ModbusHub: """Initialize the Modbus hub.""" if CONF_CLOSE_COMM_ON_ERROR in client_config: - async_create_issue( # pragma: no cover + async_create_issue( hass, DOMAIN, "deprecated_close_comm_config", From 568974fcc4d796c80d51b207960ea888891d28c4 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 16 Sep 2023 14:00:22 +0200 Subject: [PATCH 603/984] Modbus 100% test coverage (again) (#100482) --- tests/components/modbus/conftest.py | 2 +- tests/components/modbus/test_init.py | 7 +++++++ tests/components/modbus/test_sensor.py | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 460b1eb5dd3..d7e4556f746 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -136,7 +136,7 @@ async def mock_pymodbus_exception_fixture(hass, do_exception, mock_modbus): @pytest.fixture(name="mock_pymodbus_return") async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): """Trigger update call with time_changed event.""" - read_result = ReadResult(register_words) + read_result = ReadResult(register_words) if register_words else None mock_modbus.read_coils.return_value = read_result mock_modbus.read_discrete_inputs.return_value = read_result mock_modbus.read_input_registers.return_value = read_result diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index f9c7fb42b2d..5f8c0554e6d 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,6 +40,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, @@ -413,6 +414,12 @@ async def test_duplicate_entity_validator(do_config) -> None: @pytest.mark.parametrize( "do_config", [ + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLOSE_COMM_ON_ERROR: True, + }, { CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 46c38873a93..0f79a125c86 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -409,6 +409,17 @@ async def test_config_wrong_struct_sensor( False, "-1985229329", ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + }, + [0x89AB], + False, + STATE_UNAVAILABLE, + ), ( { CONF_DATA_TYPE: DataType.UINT32, @@ -751,6 +762,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["16909060", "67305985"], ), + ( + { + CONF_VIRTUAL_COUNT: 2, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201, 0x0403], + False, + [STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNKNOWN], + ), ( { CONF_SLAVE_COUNT: 3, From f99dedfb428b9fc77dd98b4cbf0ecefcf91812bf Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Sat, 16 Sep 2023 23:00:41 +0900 Subject: [PATCH 604/984] Add switchbot cloud integration (#99607) * Switches via API * Using external library * UT and checlist * Updating file .coveragerc * Update homeassistant/components/switchbot_via_api/switch.py Co-authored-by: J. Nick Koston * Update homeassistant/components/switchbot_via_api/switch.py Co-authored-by: J. Nick Koston * Update homeassistant/components/switchbot_via_api/switch.py Co-authored-by: J. Nick Koston * Review fixes * Apply suggestions from code review Co-authored-by: J. Nick Koston * This base class shouldn't know about Remote * Fixing suggestion * Sometimes, the state from the API is not updated immediately * Review changes * Some review changes * Review changes * Review change: Adding type on commands * Parameterizing some tests * Review changes * Updating .coveragerc * Fixing error handling in coordinator * Review changes * Review changes * Adding switchbot brand * Apply suggestions from code review Co-authored-by: J. Nick Koston * Review changes * Adding strict typing * Removing log in constructor --------- Co-authored-by: J. Nick Koston --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/switchbot.json | 5 + .../components/switchbot/manifest.json | 2 +- .../components/switchbot_cloud/__init__.py | 81 ++++++++++++++ .../components/switchbot_cloud/config_flow.py | 56 ++++++++++ .../components/switchbot_cloud/const.py | 7 ++ .../components/switchbot_cloud/coordinator.py | 50 +++++++++ .../components/switchbot_cloud/entity.py | 49 +++++++++ .../components/switchbot_cloud/manifest.json | 10 ++ .../components/switchbot_cloud/strings.json | 20 ++++ .../components/switchbot_cloud/switch.py | 82 ++++++++++++++ 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/switchbot_cloud/__init__.py | 20 ++++ tests/components/switchbot_cloud/conftest.py | 15 +++ .../switchbot_cloud/test_config_flow.py | 90 ++++++++++++++++ tests/components/switchbot_cloud/test_init.py | 100 ++++++++++++++++++ 22 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 homeassistant/brands/switchbot.json create mode 100644 homeassistant/components/switchbot_cloud/__init__.py create mode 100644 homeassistant/components/switchbot_cloud/config_flow.py create mode 100644 homeassistant/components/switchbot_cloud/const.py create mode 100644 homeassistant/components/switchbot_cloud/coordinator.py create mode 100644 homeassistant/components/switchbot_cloud/entity.py create mode 100644 homeassistant/components/switchbot_cloud/manifest.json create mode 100644 homeassistant/components/switchbot_cloud/strings.json create mode 100644 homeassistant/components/switchbot_cloud/switch.py create mode 100644 tests/components/switchbot_cloud/__init__.py create mode 100644 tests/components/switchbot_cloud/conftest.py create mode 100644 tests/components/switchbot_cloud/test_config_flow.py create mode 100644 tests/components/switchbot_cloud/test_init.py diff --git a/.coveragerc b/.coveragerc index ddde800cd77..e226b22381b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1252,6 +1252,9 @@ omit = homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/switch.py homeassistant/components/switchbot/lock.py + homeassistant/components/switchbot_cloud/coordinator.py + homeassistant/components/switchbot_cloud/entity.py + homeassistant/components/switchbot_cloud/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py homeassistant/components/syncthing/sensor.py diff --git a/.strict-typing b/.strict-typing index c1138119f5f..56c7bf248e1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -318,6 +318,7 @@ homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* homeassistant.components.switchbee.* +homeassistant.components.switchbot_cloud.* homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* diff --git a/CODEOWNERS b/CODEOWNERS index 7c96042caa3..8453a4893fe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1238,6 +1238,8 @@ build.json @home-assistant/supervisor /tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski +/homeassistant/components/switchbot_cloud/ @SeraphicRav +/tests/components/switchbot_cloud/ @SeraphicRav /homeassistant/components/switcher_kis/ @thecode /tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li diff --git a/homeassistant/brands/switchbot.json b/homeassistant/brands/switchbot.json new file mode 100644 index 00000000000..0909b24a146 --- /dev/null +++ b/homeassistant/brands/switchbot.json @@ -0,0 +1,5 @@ +{ + "domain": "switchbot", + "name": "SwitchBot", + "integrations": ["switchbot", "switchbot_cloud"] +} diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2259a450559..49a6af2b179 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -1,6 +1,6 @@ { "domain": "switchbot", - "name": "SwitchBot", + "name": "SwitchBot Bluetooth", "bluetooth": [ { "service_data_uuid": "00000d00-0000-1000-8000-00805f9b34fb", diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py new file mode 100644 index 00000000000..cf711fcc431 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -0,0 +1,81 @@ +"""The SwitchBot via API integration.""" +from asyncio import gather +from dataclasses import dataclass +from logging import getLogger + +from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator + +_LOGGER = getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +@dataclass +class SwitchbotDevices: + """Switchbot devices data.""" + + switches: list[Device | Remote] + + +@dataclass +class SwitchbotCloudData: + """Data to use in platforms.""" + + api: SwitchBotAPI + devices: SwitchbotDevices + + +async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: + """Set up SwitchBot via API from a config entry.""" + token = config.data[CONF_API_TOKEN] + secret = config.data[CONF_API_KEY] + + api = SwitchBotAPI(token=token, secret=secret) + try: + devices = await api.list_devices() + except InvalidAuth as ex: + _LOGGER.error( + "Invalid authentication while connecting to SwitchBot API: %s", ex + ) + return False + except CannotConnect as ex: + raise ConfigEntryNotReady from ex + _LOGGER.debug("Devices: %s", devices) + devices_and_coordinators = [ + (device, SwitchBotCoordinator(hass, api, device)) for device in devices + ] + hass.data.setdefault(DOMAIN, {}) + data = SwitchbotCloudData( + api=api, + devices=SwitchbotDevices( + switches=[ + (device, coordinator) + for device, coordinator in devices_and_coordinators + if isinstance(device, Device) + and device.device_type.startswith("Plug") + or isinstance(device, Remote) + ], + ), + ) + hass.data[DOMAIN][config.entry_id] = data + _LOGGER.debug("Switches: %s", data.devices.switches) + await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) + await gather( + *[coordinator.async_refresh() for _, coordinator in devices_and_coordinators] + ) + 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/switchbot_cloud/config_flow.py b/homeassistant/components/switchbot_cloud/config_flow.py new file mode 100644 index 00000000000..5c99567968c --- /dev/null +++ b/homeassistant/components/switchbot_cloud/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for SwitchBot via API integration.""" + +from logging import getLogger +from typing import Any + +from switchbot_api import CannotConnect, InvalidAuth, SwitchBotAPI +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, ENTRY_TITLE + +_LOGGER = getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_API_KEY): str, + } +) + + +class SwitchBotCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for SwitchBot via API.""" + + 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: + try: + await SwitchBotAPI( + token=user_input[CONF_API_TOKEN], secret=user_input[CONF_API_KEY] + ).list_devices() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_API_TOKEN], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=ENTRY_TITLE, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py new file mode 100644 index 00000000000..ef69c9c1d02 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/const.py @@ -0,0 +1,7 @@ +"""Constants for the SwitchBot Cloud integration.""" +from datetime import timedelta +from typing import Final + +DOMAIN: Final = "switchbot_cloud" +ENTRY_TITLE = "SwitchBot Cloud" +SCAN_INTERVAL = timedelta(seconds=600) diff --git a/homeassistant/components/switchbot_cloud/coordinator.py b/homeassistant/components/switchbot_cloud/coordinator.py new file mode 100644 index 00000000000..92099ccde43 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/coordinator.py @@ -0,0 +1,50 @@ +"""SwitchBot Cloud coordinator.""" +from asyncio import timeout +from logging import getLogger +from typing import Any + +from switchbot_api import CannotConnect, Device, Remote, SwitchBotAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = getLogger(__name__) + +Status = dict[str, Any] | None + + +class SwitchBotCoordinator(DataUpdateCoordinator[Status]): + """SwitchBot Cloud coordinator.""" + + _api: SwitchBotAPI + _device_id: str + _should_poll = False + + def __init__( + self, hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote + ) -> None: + """Initialize SwitchBot Cloud.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._api = api + self._device_id = device.device_id + self._should_poll = not isinstance(device, Remote) + + async def _async_update_data(self) -> Status: + """Fetch data from API endpoint.""" + if not self._should_poll: + return None + try: + _LOGGER.debug("Refreshing %s", self._device_id) + async with timeout(10): + status: Status = await self._api.get_status(self._device_id) + _LOGGER.debug("Refreshing %s with %s", self._device_id, status) + return status + except CannotConnect as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py new file mode 100644 index 00000000000..5d0e2ff09c3 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -0,0 +1,49 @@ +"""Base class for SwitchBot via API entities.""" +from typing import Any + +from switchbot_api import Commands, Device, Remote, SwitchBotAPI + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator + + +class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): + """Representation of a SwitchBot Cloud entity.""" + + _api: SwitchBotAPI + _switchbot_state: dict[str, Any] | None = None + _attr_has_entity_name = True + + def __init__( + self, + api: SwitchBotAPI, + device: Device | Remote, + coordinator: SwitchBotCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._api = api + self._attr_unique_id = device.device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=device.device_name, + manufacturer="SwitchBot", + model=device.device_type, + ) + + async def send_command( + self, + command: Commands, + command_type: str = "command", + parameters: dict | str = "default", + ) -> None: + """Send command to device.""" + await self._api.send_command( + self._attr_unique_id, + command, + command_type, + parameters, + ) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json new file mode 100644 index 00000000000..0451217ca5f --- /dev/null +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "switchbot_cloud", + "name": "SwitchBot Cloud", + "codeowners": ["@SeraphicRav"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", + "iot_class": "cloud_polling", + "loggers": ["switchbot-api"], + "requirements": ["switchbot-api==1.1.0"] +} diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json new file mode 100644 index 00000000000..11e92e6dfa3 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "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%]" + } + } +} diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py new file mode 100644 index 00000000000..c63b1713b8d --- /dev/null +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -0,0 +1,82 @@ +"""Support for SwitchBot switch.""" +from typing import Any + +from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotAPI + +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 +from .coordinator import SwitchBotCoordinator +from .entity import SwitchBotCloudEntity + + +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] + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.switches + ) + + +class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity): + """Representation of a SwitchBot switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_name = None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.send_command(CommonCommands.ON) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.send_command(CommonCommands.OFF) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.data: + return + self._attr_is_on = self.coordinator.data.get("power") == PowerState.ON.value + self.async_write_ha_state() + + +class SwitchBotCloudRemoteSwitch(SwitchBotCloudSwitch): + """Representation of a SwitchBot switch provider by a remote.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + +class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): + """Representation of a SwitchBot plug switch.""" + + _attr_device_class = SwitchDeviceClass.OUTLET + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> SwitchBotCloudSwitch: + """Make a SwitchBotCloudSwitch or SwitchBotCloudRemoteSwitch.""" + if isinstance(device, Remote): + return SwitchBotCloudRemoteSwitch(api, device, coordinator) + if "Plug" in device.device_type: + return SwitchBotCloudPlugSwitch(api, device, coordinator) + raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 98935086b88..229682eff1d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -455,6 +455,7 @@ FLOWS = { "surepetcare", "switchbee", "switchbot", + "switchbot_cloud", "switcher_kis", "syncthing", "syncthru", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 779ee92e9fe..a65239316ed 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5514,9 +5514,20 @@ }, "switchbot": { "name": "SwitchBot", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "integrations": { + "switchbot": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "SwitchBot Bluetooth" + }, + "switchbot_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "SwitchBot Cloud" + } + } }, "switcher_kis": { "name": "Switcher", diff --git a/mypy.ini b/mypy.ini index 3d6e4e1b2b6..d2c2a66d738 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2943,6 +2943,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.switchbot_cloud.*] +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.switcher_kis.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index a87177e296d..52341321ced 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2502,6 +2502,9 @@ surepy==0.8.0 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 +# homeassistant.components.switchbot_cloud +switchbot-api==1.1.0 + # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2d44de0327..1de5c8ae574 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1847,6 +1847,9 @@ sunwatcher==0.2.1 # homeassistant.components.surepetcare surepy==0.8.0 +# homeassistant.components.switchbot_cloud +switchbot-api==1.1.0 + # homeassistant.components.system_bridge systembridgeconnector==3.8.2 diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py new file mode 100644 index 00000000000..72d23c837ac --- /dev/null +++ b/tests/components/switchbot_cloud/__init__.py @@ -0,0 +1,20 @@ +"""Tests for the SwitchBot Cloud integration.""" +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def configure_integration(hass: HomeAssistant) -> MockConfigEntry: + """Configure the integration.""" + config = { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-api-key", + } + entry = MockConfigEntry( + domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" + ) + entry.add_to_hass(hass) + + return entry diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py new file mode 100644 index 00000000000..b96d7638797 --- /dev/null +++ b/tests/components/switchbot_cloud/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the SwitchBot via API 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.switchbot_cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/switchbot_cloud/test_config_flow.py b/tests/components/switchbot_cloud/test_config_flow.py new file mode 100644 index 00000000000..6fdf8fecdb7 --- /dev/null +++ b/tests/components/switchbot_cloud/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the SwitchBot via API config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.switchbot_cloud.config_flow import ( + CannotConnect, + InvalidAuth, +) +from homeassistant.components.switchbot_cloud.const import DOMAIN, ENTRY_TITLE +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def _fill_out_form_and_assert_entry_created( + hass: HomeAssistant, flow_id: str, mock_setup_entry: AsyncMock +) -> None: + """Util function to fill out a form and assert that a config entry is created.""" + with patch( + "homeassistant.components.switchbot_cloud.config_flow.SwitchBotAPI.list_devices", + return_value=[], + ): + result_configure = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + }, + ) + await hass.async_block_till_done() + + assert result_configure["type"] == FlowResultType.CREATE_ENTRY + assert result_configure["title"] == ENTRY_TITLE + assert result_configure["data"] == { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + } + mock_setup_entry.assert_called_once() + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result_init["type"] == FlowResultType.FORM + assert not result_init["errors"] + + await _fill_out_form_and_assert_entry_created( + hass, result_init["flow_id"], mock_setup_entry + ) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_fails( + hass: HomeAssistant, error: Exception, message: str, mock_setup_entry: AsyncMock +) -> None: + """Test we handle error cases.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.switchbot_cloud.config_flow.SwitchBotAPI.list_devices", + side_effect=error, + ): + result_configure = await hass.config_entries.flow.async_configure( + result_init["flow_id"], + { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + }, + ) + + assert result_configure["type"] == FlowResultType.FORM + assert result_configure["errors"] == {"base": message} + await hass.async_block_till_done() + + await _fill_out_form_and_assert_entry_created( + hass, result_init["flow_id"], mock_setup_entry + ) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py new file mode 100644 index 00000000000..48f0021bdb4 --- /dev/null +++ b/tests/components/switchbot_cloud/test_init.py @@ -0,0 +1,100 @@ +"""Tests for the SwitchBot Cloud integration init.""" + +from unittest.mock import patch + +import pytest +from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState + +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +@pytest.fixture +def mock_list_devices(): + """Mock list_devices.""" + with patch.object(SwitchBotAPI, "list_devices") as mock_list_devices: + yield mock_list_devices + + +@pytest.fixture +def mock_get_status(): + """Mock get_status.""" + with patch.object(SwitchBotAPI, "get_status") as mock_get_status: + yield mock_get_status + + +async def test_setup_entry_success( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test successful setup of entry.""" + mock_list_devices.return_value = [ + Device( + deviceId="test-id", + deviceName="test-name", + deviceType="Plug", + hubDeviceId="test-hub-id", + ) + ] + mock_get_status.return_value = {"power": PowerState.ON.value} + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_called() + + +@pytest.mark.parametrize( + ("error", "state"), + [ + (InvalidAuth, ConfigEntryState.SETUP_ERROR), + (CannotConnect, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry_fails_when_listing_devices( + hass: HomeAssistant, + error: Exception, + state: ConfigEntryState, + mock_list_devices, + mock_get_status, +) -> None: + """Test error handling when list_devices in setup of entry.""" + mock_list_devices.side_effect = error + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == state + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_not_called() + + +async def test_setup_entry_fails_when_refreshing( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test error handling in get_status in setup of entry.""" + mock_list_devices.return_value = [ + Device( + deviceId="test-id", + deviceName="test-name", + deviceType="Plug", + hubDeviceId="test-hub-id", + ) + ] + mock_get_status.side_effect = CannotConnect + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_called() From 7b71d276379aa221690296fae9d29ea194a70ecb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 16 Sep 2023 16:20:24 +0200 Subject: [PATCH 605/984] Pass function correctly to Withings API (#100391) * Pass function correctly to Withings API * Add more typing --- homeassistant/components/withings/api.py | 35 ++++++++++++++---------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py index 3a81fb298ea..f9739d3fb6f 100644 --- a/homeassistant/components/withings/api.py +++ b/homeassistant/components/withings/api.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Awaitable, Callable, Iterable from typing import Any import arrow @@ -63,7 +63,7 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): ) return response.json() - async def _do_retry(self, func, attempts=3) -> Any: + async def _do_retry(self, func: Callable[[], Awaitable[Any]], attempts=3) -> Any: """Retry a function call. Withings' API occasionally and incorrectly throws errors. @@ -97,8 +97,8 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): ) -> MeasureGetMeasResponse: """Get measurements.""" - return await self._do_retry( - await self._hass.async_add_executor_job( + async def call_super() -> MeasureGetMeasResponse: + return await self._hass.async_add_executor_job( self.measure_get_meas, meastype, category, @@ -107,7 +107,8 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): offset, lastupdate, ) - ) + + return await self._do_retry(call_super) async def async_sleep_get_summary( self, @@ -119,8 +120,8 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): ) -> SleepGetSummaryResponse: """Get sleep data.""" - return await self._do_retry( - await self._hass.async_add_executor_job( + async def call_super() -> SleepGetSummaryResponse: + return await self._hass.async_add_executor_job( self.sleep_get_summary, data_fields, startdateymd, @@ -128,16 +129,18 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): offset, lastupdate, ) - ) + + return await self._do_retry(call_super) async def async_notify_list( self, appli: NotifyAppli | None = None ) -> NotifyListResponse: """List webhooks.""" - return await self._do_retry( - await self._hass.async_add_executor_job(self.notify_list, appli) - ) + async def call_super() -> NotifyListResponse: + return await self._hass.async_add_executor_job(self.notify_list, appli) + + return await self._do_retry(call_super) async def async_notify_subscribe( self, @@ -147,19 +150,21 @@ class ConfigEntryWithingsApi(AbstractWithingsApi): ) -> None: """Subscribe to webhook.""" - return await self._do_retry( + async def call_super() -> None: await self._hass.async_add_executor_job( self.notify_subscribe, callbackurl, appli, comment ) - ) + + await self._do_retry(call_super) async def async_notify_revoke( self, callbackurl: str | None = None, appli: NotifyAppli | None = None ) -> None: """Revoke webhook.""" - return await self._do_retry( + async def call_super() -> None: await self._hass.async_add_executor_job( self.notify_revoke, callbackurl, appli ) - ) + + await self._do_retry(call_super) From 8a98a0e830e9381d9959d8b7b7e732b27f6be5ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 Sep 2023 09:57:43 -0500 Subject: [PATCH 606/984] Avoid writing unifiprotect state when nothing has changed (#100439) --- .../components/unifiprotect/binary_sensor.py | 20 ++++++++ .../components/unifiprotect/button.py | 14 +++++ .../components/unifiprotect/media_player.py | 20 ++++++++ .../components/unifiprotect/number.py | 18 +++++++ .../components/unifiprotect/select.py | 51 ++++++++++++------- .../components/unifiprotect/sensor.py | 38 +++++++++++++- .../components/unifiprotect/switch.py | 27 +++++++--- 7 files changed, 163 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 668fe479e1f..8f8bcab8ede 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -621,3 +621,23 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): if not is_on: self._event = None self._attr_extra_state_attributes = {} + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the is_on, _attr_extra_state_attributes, and available are ever + updated for these entities, and since the websocket update for the + device will trigger an update for all entities connected to the device, + we want to avoid writing state unless something has actually changed. + """ + previous_is_on = self._attr_is_on + previous_available = self._attr_available + previous_extra_state_attributes = self._attr_extra_state_attributes + self._async_update_device_from_protect(device) + if ( + self._attr_is_on != previous_is_on + or self._attr_extra_state_attributes != previous_extra_state_attributes + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 3306743b707..bc93c156866 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -193,3 +193,17 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): if self.entity_description.ufp_press is not None: await getattr(self.device, self.entity_description.ufp_press)() + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only available is updated for these entities, and since the websocket + update for the device will trigger an update for all entities connected + to the device, we want to avoid writing state unless something has + actually changed. + """ + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if self._attr_available != previous_available: + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index c3f4e58e247..df5ea40d4a9 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -115,6 +115,26 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): ) self._attr_available = is_connected and updated_device.feature_flags.has_speaker + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the state, volume, and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_state = self._attr_state + previous_available = self._attr_available + previous_volume_level = self._attr_volume_level + self._async_update_device_from_protect(device) + if ( + self._attr_state != previous_state + or self._attr_volume_level != previous_volume_level + or self._attr_available != previous_available + ): + self.async_write_ha_state() + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 247e401b2ca..08bc9f38527 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -268,3 +268,21 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.ufp_set(self.device, value) + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 26a03fb7967..7605be17fc9 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -349,9 +349,9 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): description: ProtectSelectEntityDescription, ) -> None: """Initialize the unifi protect select entity.""" + self._async_set_options(data, description) super().__init__(data, device, description) self._attr_name = f"{self.device.display_name} {self.entity_description.name}" - self._async_set_options() @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -366,31 +366,28 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): _LOGGER.debug( "Updating dynamic select options for %s", entity_description.name ) - self._async_set_options() + self._async_set_options(self.data, entity_description) + if (unifi_value := entity_description.get_ufp_value(device)) is None: + unifi_value = TYPE_EMPTY_VALUE + self._attr_current_option = self._unifi_to_hass_options.get( + unifi_value, unifi_value + ) @callback - def _async_set_options(self) -> None: + def _async_set_options( + self, data: ProtectData, description: ProtectSelectEntityDescription + ) -> None: """Set options attributes from UniFi Protect device.""" - - if self.entity_description.ufp_options is not None: - options = self.entity_description.ufp_options + if (ufp_options := description.ufp_options) is not None: + options = ufp_options else: - assert self.entity_description.ufp_options_fn is not None - options = self.entity_description.ufp_options_fn(self.data.api) + assert description.ufp_options_fn is not None + options = description.ufp_options_fn(data.api) self._attr_options = [item["name"] for item in options] self._hass_to_unifi_options = {item["name"]: item["id"] for item in options} self._unifi_to_hass_options = {item["id"]: item["name"] for item in options} - @property - def current_option(self) -> str: - """Return the current selected option.""" - - unifi_value = self.entity_description.get_ufp_value(self.device) - if unifi_value is None: - unifi_value = TYPE_EMPTY_VALUE - return self._unifi_to_hass_options.get(unifi_value, unifi_value) - async def async_select_option(self, option: str) -> None: """Change the Select Entity Option.""" @@ -404,3 +401,23 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) await self.entity_description.ufp_set(self.device, unifi_value) + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the options, option, and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_option = self._attr_current_option + previous_options = self._attr_options + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_current_option != previous_option + or self._attr_options != previous_options + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index d842b13b015..756da49eb4d 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -710,22 +710,56 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): entity_description: ProtectSensorEntityDescription - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectNVRSensor(ProtectNVREntity, SensorEntity): """A Ubiquiti UniFi Protect Sensor.""" entity_description: ProtectSensorEntityDescription - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectEventSensor(EventEntityMixin, SensorEntity): """A UniFi Protect Device Sensor with access tokens.""" diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index ea2d8256cbe..f1e6185b010 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -420,21 +420,36 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): self._attr_name = f"{self.device.display_name} {self.entity_description.name}" self._switch_type = self.entity_description.key - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self.entity_description.get_ufp_value(self.device) is True + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) + self._attr_is_on = self.entity_description.get_ufp_value(self.device) is True async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self.entity_description.ufp_set(self.device, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.entity_description.ufp_set(self.device, False) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the is_on and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_is_on = self._attr_is_on + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_is_on != previous_is_on + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectNVRSwitch(ProtectNVREntity, SwitchEntity): """A UniFi Protect NVR Switch.""" From c8265a86b26b075d98ec7962851a6ff10e7f308f Mon Sep 17 00:00:00 2001 From: Kevin <36297312+kevin-kraus@users.noreply.github.com> Date: Sat, 16 Sep 2023 17:12:00 +0200 Subject: [PATCH 607/984] Bump python-androidtv to 0.0.72 (#100441) Co-authored-by: J. Nick Koston --- homeassistant/components/androidtv/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index f782db79879..b8c020e6e1e 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -8,8 +8,8 @@ "iot_class": "local_polling", "loggers": ["adb_shell", "androidtv", "pure_python_adb"], "requirements": [ - "adb-shell[async]==0.4.3", - "androidtv[async]==0.0.70", + "adb-shell[async]==0.4.4", + "androidtv[async]==0.0.72", "pure-python-adb[async]==0.3.0.dev0" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 52341321ced..050cd284489 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,7 +147,7 @@ accuweather==1.0.0 adax==0.2.0 # homeassistant.components.androidtv -adb-shell[async]==0.4.3 +adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder adext==0.4.2 @@ -406,7 +406,7 @@ amberelectric==1.0.4 amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.70 +androidtv[async]==0.0.72 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1de5c8ae574..91ac5295847 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ accuweather==1.0.0 adax==0.2.0 # homeassistant.components.androidtv -adb-shell[async]==0.4.3 +adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder adext==0.4.2 @@ -375,7 +375,7 @@ airtouch4pyapi==1.0.5 amberelectric==1.0.4 # homeassistant.components.androidtv -androidtv[async]==0.0.70 +androidtv[async]==0.0.72 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 From 01ecef7f05b01883eec3af94cd559fb7910fc916 Mon Sep 17 00:00:00 2001 From: "J.P. Krauss" Date: Sat, 16 Sep 2023 09:16:15 -0700 Subject: [PATCH 608/984] Fix error is measurement is not sent by AirNow (#100477) --- homeassistant/components/airnow/sensor.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 09393741d63..c83232c273a 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -58,6 +58,16 @@ class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMi """Describes Airnow sensor entity.""" +def station_extra_attrs(data: dict[str, Any]) -> dict[str, Any]: + """Process extra attributes for station location (if available).""" + if ATTR_API_STATION in data: + return { + "lat": data.get(ATTR_API_STATION_LATITUDE), + "long": data.get(ATTR_API_STATION_LONGITUDE), + } + return {} + + SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_AQI, @@ -93,10 +103,7 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( translation_key="station", icon="mdi:blur", value_fn=lambda data: data.get(ATTR_API_STATION), - extra_state_attributes_fn=lambda data: { - "lat": data[ATTR_API_STATION_LATITUDE], - "long": data[ATTR_API_STATION_LONGITUDE], - }, + extra_state_attributes_fn=station_extra_attrs, ), ) From f715f5c76fb892305fe5ee33fa2f8667ee2bf571 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 16 Sep 2023 18:48:41 +0200 Subject: [PATCH 609/984] Only get meteo france alert coordinator if it exists (#100493) Only get meteo france coordinator if it exists --- homeassistant/components/meteo_france/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 98cb4665614..dd8fd4af83b 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -196,9 +196,9 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN] - coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data[ + coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( COORDINATOR_ALERT - ] + ) entities: list[MeteoFranceSensor[Any]] = [ MeteoFranceSensor(coordinator_forecast, description) From 81af45347f6b8eab93f4037fe11bd7d5ea3bba85 Mon Sep 17 00:00:00 2001 From: Jieyu Yan Date: Sat, 16 Sep 2023 11:58:00 -0700 Subject: [PATCH 610/984] Add fan modes in Lyric integration (#100420) * Add fan modes in Lyric integration * add fan_mode only when available * move supported_features to init * mapped fan_modes to built-in modes * log KeyError for fan_modes --- homeassistant/components/lyric/__init__.py | 1 + homeassistant/components/lyric/climate.py | 72 +++++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 3e83fedb72a..d048b31d0b0 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -133,6 +133,7 @@ class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]): self._location = location self._mac_id = device.macID self._update_thermostat = coordinator.data.update_thermostat + self._update_fan = coordinator.data.update_fan @property def unique_id(self) -> str: diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 1522f167a4a..d0bad55ff14 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -14,6 +14,9 @@ import voluptuous as vol from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_DIFFUSE, + FAN_ON, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -67,6 +70,10 @@ LYRIC_HVAC_MODE_HEAT = "Heat" LYRIC_HVAC_MODE_COOL = "Cool" LYRIC_HVAC_MODE_HEAT_COOL = "Auto" +LYRIC_FAN_MODE_ON = "On" +LYRIC_FAN_MODE_AUTO = "Auto" +LYRIC_FAN_MODE_DIFFUSE = "Circulate" + LYRIC_HVAC_MODES = { HVACMode.OFF: LYRIC_HVAC_MODE_OFF, HVACMode.HEAT: LYRIC_HVAC_MODE_HEAT, @@ -81,6 +88,18 @@ HVAC_MODES = { LYRIC_HVAC_MODE_HEAT_COOL: HVACMode.HEAT_COOL, } +LYRIC_FAN_MODES = { + FAN_ON: LYRIC_FAN_MODE_ON, + FAN_AUTO: LYRIC_FAN_MODE_AUTO, + FAN_DIFFUSE: LYRIC_FAN_MODE_DIFFUSE, +} + +FAN_MODES = { + LYRIC_FAN_MODE_ON: FAN_ON, + LYRIC_FAN_MODE_AUTO: FAN_AUTO, + LYRIC_FAN_MODE_DIFFUSE: FAN_DIFFUSE, +} + HVAC_ACTIONS = { LYRIC_HVAC_ACTION_OFF: HVACAction.OFF, LYRIC_HVAC_ACTION_HEAT: HVACAction.HEATING, @@ -179,6 +198,25 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): ): self._attr_hvac_modes.append(HVACMode.HEAT_COOL) + # Setup supported features + if device.changeableValues.thermostatSetpointStatus: + self._attr_supported_features = SUPPORT_FLAGS_LCC + else: + self._attr_supported_features = SUPPORT_FLAGS_TCC + + # Setup supported fan modes + if device_fan_modes := device.settings.attributes.get("fan", {}).get( + "allowedModes" + ): + self._attr_fan_modes = [ + FAN_MODES[device_fan_mode] + for device_fan_mode in device_fan_modes + if device_fan_mode in FAN_MODES + ] + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + super().__init__( coordinator, location, @@ -187,13 +225,6 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): ) self.entity_description = description - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self.device.changeableValues.thermostatSetpointStatus: - return SUPPORT_FLAGS_LCC - return SUPPORT_FLAGS_TCC - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -268,6 +299,16 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): return device.maxHeatSetpoint return device.maxCoolSetpoint + @property + def fan_mode(self) -> str | None: + """Return current fan mode.""" + device = self.device + return FAN_MODES.get( + device.settings.attributes.get("fan", {}) + .get("changeableValues", {}) + .get("mode") + ) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if self.hvac_mode == HVACMode.OFF: @@ -390,3 +431,20 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) await self.coordinator.async_refresh() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + _LOGGER.debug("Set fan mode: %s", fan_mode) + try: + _LOGGER.debug("Fan mode passed to lyric: %s", LYRIC_FAN_MODES[fan_mode]) + await self._update_fan( + self.location, self.device, mode=LYRIC_FAN_MODES[fan_mode] + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + except KeyError: + _LOGGER.error( + "The fan mode requested does not have a corresponding mode in lyric: %s", + fan_mode, + ) + await self.coordinator.async_refresh() From 9931f45532586b54519377d389859483897c83d7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 16 Sep 2023 21:14:52 +0200 Subject: [PATCH 611/984] Deprecate modbus parameter retry_on_empty (#100292) --- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/modbus.py | 22 ++++++++++++++++++-- homeassistant/components/modbus/strings.json | 4 ++++ tests/components/modbus/test_init.py | 7 +++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 875669e6dd7..85fba66b68a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -344,7 +344,7 @@ MODBUS_SCHEMA = vol.Schema( vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, vol.Optional(CONF_RETRIES, default=3): cv.positive_int, - vol.Optional(CONF_RETRY_ON_EMPTY, default=False): cv.boolean, + vol.Optional(CONF_RETRY_ON_EMPTY): cv.boolean, vol.Optional(CONF_MSG_WAIT): cv.positive_int, vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [BINARY_SENSOR_SCHEMA] diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index db8a4d47fdc..4ef205aace3 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -276,7 +276,25 @@ class ModbusHub: }, ) _LOGGER.warning( - "`close_comm_on_error`: is deprecated and will be remove in version 2024.4" + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + if CONF_RETRY_ON_EMPTY in client_config: + async_create_issue( + hass, + DOMAIN, + "deprecated_retry_on_empty", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_retry_on_empty", + translation_placeholders={ + "config_key": "retry_on_empty", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`retry_on_empty`: is deprecated and will be removed in version 2024.4" ) # generic configuration self._client: ModbusBaseClient | None = None @@ -298,7 +316,7 @@ class ModbusHub: "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], "retries": client_config[CONF_RETRIES], - "retry_on_empty": client_config[CONF_RETRY_ON_EMPTY], + "retry_on_empty": True, } if self._config_type == SERIAL: # serial configuration diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 780757a3eeb..5f45d0df596 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -73,6 +73,10 @@ "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." + }, + "deprecated_retry_on_empty": { + "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\nRetry on empty is automatically applied, see [the documentation]({url}) for other error handling parameters." } } } diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5f8c0554e6d..e66115f24d9 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -46,6 +46,7 @@ from homeassistant.components.modbus.const import ( CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, + CONF_RETRY_ON_EMPTY, CONF_SLAVE_COUNT, CONF_STOPBITS, CONF_SWAP, @@ -420,6 +421,12 @@ async def test_duplicate_entity_validator(do_config) -> None: CONF_PORT: TEST_PORT_TCP, CONF_CLOSE_COMM_ON_ERROR: True, }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_RETRY_ON_EMPTY: True, + }, { CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, From ddeb2854aa3e1f1fc88df738976c7a6f43d0d724 Mon Sep 17 00:00:00 2001 From: Dennis Date: Sun, 17 Sep 2023 00:40:16 +0200 Subject: [PATCH 612/984] Added device class to speedtestdotnet sensor entities. (#100500) Added device class to sensor entities. --- homeassistant/components/speedtestdotnet/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index ccd2008503c..af41c400e0b 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any, cast from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, @@ -45,12 +46,14 @@ SENSOR_TYPES: tuple[SpeedtestSensorEntityDescription, ...] = ( translation_key="ping", native_unit_of_measurement=UnitOfTime.MILLISECONDS, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, ), SpeedtestSensorEntityDescription( key="download", translation_key="download", native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, value=lambda value: round(value / 10**6, 2), ), SpeedtestSensorEntityDescription( @@ -58,6 +61,7 @@ SENSOR_TYPES: tuple[SpeedtestSensorEntityDescription, ...] = ( translation_key="upload", native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, value=lambda value: round(value / 10**6, 2), ), ) From 48f9a38c7487f49447ec0740edc33d7b2e8f146b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 17 Sep 2023 16:49:21 +0200 Subject: [PATCH 613/984] Update numpy to 1.26.0 (#100512) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/opencv/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 7b59879025e..e166ca716cb 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==1.23.2"] + "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 315d063d6aa..ce519de1b67 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==1.23.2", "pyiqvia==2022.04.0"] + "requirements": ["numpy==1.26.0", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index da541974b46..3c484385934 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/opencv", "iot_class": "local_push", - "requirements": ["numpy==1.23.2", "opencv-python-headless==4.6.0.66"] + "requirements": ["numpy==1.26.0", "opencv-python-headless==4.6.0.66"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 47a4ddd0653..45e9a96d759 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.23.2"] + "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.26.0"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 71952431b5a..bfd3e77ee50 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==1.23.2", + "numpy==1.26.0", "Pillow==10.0.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 9bb5c4296c5..0adbf623346 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/trend", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==1.23.2"] + "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 98846c0a968..5aa3a010d64 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -112,7 +112,7 @@ httpcore==0.17.3 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.23.2 +numpy==1.26.0 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated diff --git a/requirements_all.txt b/requirements_all.txt index 050cd284489..b761d2bcaa1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1315,7 +1315,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.23.2 +numpy==1.26.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91ac5295847..010728ced06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1014,7 +1014,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.23.2 +numpy==1.26.0 # homeassistant.components.google oauth2client==4.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 8780b9d0743..e0e00ebc958 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -113,7 +113,7 @@ httpcore==0.17.3 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.23.2 +numpy==1.26.0 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated From 7aa02b86214214705142106cbd7b96b8bec6a6db Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 17 Sep 2023 07:50:17 -0700 Subject: [PATCH 614/984] Bump opower to 0.0.34 (#100501) --- 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 05e89ea96d4..002495b9517 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.33"] + "requirements": ["opower==0.0.34"] } diff --git a/requirements_all.txt b/requirements_all.txt index b761d2bcaa1..4e14af04637 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1378,7 +1378,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.33 +opower==0.0.34 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 010728ced06..25d825691e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1050,7 +1050,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.33 +opower==0.0.34 # homeassistant.components.oralb oralb-ble==0.17.6 From 2794ab1782a6ab43ab15719cfdc8713c241cc894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 17 Sep 2023 19:11:44 +0300 Subject: [PATCH 615/984] Fix huawei_lte current month up/download sensor error on delete (#100506) Deleting one of them prematurely deleted the last reset item subscription that is shared between the two. --- homeassistant/components/huawei_lte/__init__.py | 6 ++++-- homeassistant/components/huawei_lte/binary_sensor.py | 4 +++- homeassistant/components/huawei_lte/device_tracker.py | 4 ++-- homeassistant/components/huawei_lte/sensor.py | 4 ++-- homeassistant/components/huawei_lte/switch.py | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index f21f084a544..929ca0193af 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -143,9 +143,11 @@ class Router: url: str data: dict[str, Any] = field(default_factory=dict, init=False) - subscriptions: dict[str, set[str]] = field( + # Values are lists rather than sets, because the same item may be used by more than + # one thing, such as MonthDuration for CurrentMonth{Download,Upload}. + subscriptions: dict[str, list[str]] = field( default_factory=lambda: defaultdict( - set, ((x, {"initial_scan"}) for x in ALL_KEYS) + list, ((x, ["initial_scan"]) for x in ALL_KEYS) ), init=False, ) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index a1a26b51657..2d96a4e0426 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -65,7 +65,9 @@ class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntityWithDevice, BinarySensorEntit 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].add(f"{BINARY_SENSOR_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append( + f"{BINARY_SENSOR_DOMAIN}/{self.item}" + ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from needed data on remove.""" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index b8833b24d92..665c96e4888 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -90,8 +90,8 @@ async def async_setup_entry( async_add_entities(known_entities, True) # Tell parent router to poll hosts list to gather new devices - router.subscriptions[KEY_LAN_HOST_INFO].add(_DEVICE_SCAN) - router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) + router.subscriptions[KEY_LAN_HOST_INFO].append(_DEVICE_SCAN) + router.subscriptions[KEY_WLAN_HOST_LIST].append(_DEVICE_SCAN) async def _async_maybe_add_new_entities(unique_id: str) -> None: """Add new entities if the update signal comes from our router.""" diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 133b569c751..a4321bfd93f 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -724,9 +724,9 @@ class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): 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].add(f"{SENSOR_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append(f"{SENSOR_DOMAIN}/{self.item}") if self.entity_description.last_reset_item: - self.router.subscriptions[self.key].add( + self.router.subscriptions[self.key].append( f"{SENSOR_DOMAIN}/{self.entity_description.last_reset_item}" ) diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index 2fe064d6300..f75cf14e89b 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -69,7 +69,7 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): 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].add(f"{SWITCH_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append(f"{SWITCH_DOMAIN}/{self.item}") async def async_will_remove_from_hass(self) -> None: """Unsubscribe from needed data on remove.""" From dd1dc52994affc953e02ae4ce7e23e7ce197ce29 Mon Sep 17 00:00:00 2001 From: Markus Friedli Date: Sun, 17 Sep 2023 20:00:09 +0200 Subject: [PATCH 616/984] Fix broken reconnect capability of fritzbox_callmonitor (#100526) --- homeassistant/components/fritz/manifest.json | 2 +- homeassistant/components/fritzbox_callmonitor/manifest.json | 2 +- homeassistant/components/fritzbox_callmonitor/sensor.py | 6 +++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 8d52115d49b..d8d8f6b94bf 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/fritz", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection[qr]==1.12.2", "xmltodict==0.13.0"], + "requirements": ["fritzconnection[qr]==1.13.2", "xmltodict==0.13.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index c3c305ab07e..4e5c60091c9 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection[qr]==1.12.2"] + "requirements": ["fritzconnection[qr]==1.13.2"] } diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 11c3166fd88..cc239895c38 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -192,7 +192,11 @@ class FritzBoxCallMonitor: _LOGGER.debug("Setting up socket connection") try: self.connection = FritzMonitor(address=self.host, port=self.port) - kwargs: dict[str, Any] = {"event_queue": self.connection.start()} + kwargs: dict[str, Any] = { + "event_queue": self.connection.start( + reconnect_tries=50, reconnect_delay=120 + ) + } Thread(target=self._process_events, kwargs=kwargs).start() except OSError as err: self.connection = None diff --git a/requirements_all.txt b/requirements_all.txt index 4e14af04637..2329462c46b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -834,7 +834,7 @@ freesms==0.2.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection[qr]==1.12.2 +fritzconnection[qr]==1.13.2 # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25d825691e4..c17717ce7af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -656,7 +656,7 @@ freebox-api==1.1.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection[qr]==1.12.2 +fritzconnection[qr]==1.13.2 # homeassistant.components.google_translate gTTS==2.2.4 From 868afc037efe3bd6de3628bc415f64feb22a6d71 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 17 Sep 2023 22:28:52 +0200 Subject: [PATCH 617/984] Try Reolink ONVIF long polling if ONVIF push not supported (#100375) --- homeassistant/components/reolink/host.py | 56 ++++++++++++++++++------ 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index a43dbce9a7c..2487013b032 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -61,7 +61,8 @@ class ReolinkHost: ) self.webhook_id: str | None = None - self._onvif_supported: bool = True + self._onvif_push_supported: bool = True + self._onvif_long_poll_supported: bool = True self._base_url: str = "" self._webhook_url: str = "" self._webhook_reachable: bool = False @@ -97,7 +98,9 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) - self._onvif_supported = self._api.supported(None, "ONVIF") + onvif_supported = self._api.supported(None, "ONVIF") + self._onvif_push_supported = onvif_supported + self._onvif_long_poll_supported = onvif_supported enable_rtsp = None enable_onvif = None @@ -109,7 +112,7 @@ class ReolinkHost: ) enable_rtsp = True - if not self._api.onvif_enabled and self._onvif_supported: + if not self._api.onvif_enabled and onvif_supported: _LOGGER.debug( "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name ) @@ -157,11 +160,11 @@ class ReolinkHost: self._unique_id = format_mac(self._api.mac_address) - if self._onvif_supported: + if self._onvif_push_supported: try: await self.subscribe() except NotSupportedError: - self._onvif_supported = False + self._onvif_push_supported = False self.unregister_webhook() await self._api.unsubscribe() else: @@ -179,12 +182,27 @@ class ReolinkHost: self._cancel_onvif_check = async_call_later( self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif ) - if not self._onvif_supported: + if not self._onvif_push_supported: _LOGGER.debug( - "Camera model %s does not support ONVIF, using fast polling instead", + "Camera model %s does not support ONVIF push, using ONVIF long polling instead", self._api.model, ) - await self._async_poll_all_motion() + try: + await self._async_start_long_polling(initial=True) + except NotSupportedError: + _LOGGER.debug( + "Camera model %s does not support ONVIF long polling, using fast polling instead", + self._api.model, + ) + self._onvif_long_poll_supported = False + await self._api.unsubscribe() + await self._async_poll_all_motion() + else: + self._cancel_long_poll_check = async_call_later( + self._hass, + FIRST_ONVIF_LONG_POLL_TIMEOUT, + self._async_check_onvif_long_poll, + ) if self._api.sw_version_update_required: ir.async_create_issue( @@ -317,11 +335,22 @@ class ReolinkHost: str(err), ) - async def _async_start_long_polling(self): + async def _async_start_long_polling(self, initial=False): """Start ONVIF long polling task.""" if self._long_poll_task is None: try: await self._api.subscribe(sub_type=SubType.long_poll) + except NotSupportedError as err: + if initial: + raise err + # make sure the long_poll_task is always created to try again later + if not self._lost_subscription: + self._lost_subscription = True + _LOGGER.error( + "Reolink %s event long polling subscription lost: %s", + self._api.nvr_name, + str(err), + ) except ReolinkError as err: # make sure the long_poll_task is always created to try again later if not self._lost_subscription: @@ -381,12 +410,11 @@ class ReolinkHost: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" - if not self._onvif_supported: - return - try: - await self._renew(SubType.push) - if self._long_poll_task is not None: + if self._onvif_push_supported: + await self._renew(SubType.push) + + if self._onvif_long_poll_supported and self._long_poll_task is not None: if not self._api.subscribed(SubType.long_poll): _LOGGER.debug("restarting long polling task") # To prevent 5 minute request timeout From 6acb182c38788a768d892ca154e484b917c13e1f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 00:05:29 +0200 Subject: [PATCH 618/984] Fix full black run condition [ci] (#100532) --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c20886f2342..2ac6773b6e9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -311,6 +311,7 @@ jobs: env.BLACK_CACHE_VERSION }}-${{ steps.generate-black-key.outputs.version }}-${{ env.HA_SHORT_VERSION }}- - name: Run black (fully) + if: needs.info.outputs.test_full_suite == 'true' env: BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | From f6243a1f79395c2ae4e642e81302399d712b7f66 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 17 Sep 2023 22:38:08 +0000 Subject: [PATCH 619/984] Add `event` platform for Shelly gen2 devices (#99659) * Add event platform for gen2 devices * Add tests * Add removal condition * Simplify RpcEventDescription; fix availability * Improve names and docstrings * Improve the event entity name * Use async_on_remove() * Improve tests coverage * Improve tests coverage * Prefix the entity name with the device name in the old way * Black * Use DeviceInfo object --- homeassistant/components/shelly/__init__.py | 1 + .../components/shelly/coordinator.py | 16 +++ homeassistant/components/shelly/event.py | 107 ++++++++++++++++++ homeassistant/components/shelly/utils.py | 10 ++ tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_event.py | 70 ++++++++++++ tests/components/shelly/test_utils.py | 13 +++ 7 files changed, 218 insertions(+) create mode 100644 homeassistant/components/shelly/event.py create mode 100644 tests/components/shelly/test_event.py diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 09d9e3655f0..29a0506fcc0 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -73,6 +73,7 @@ RPC_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d0530efa149..c19aac93dab 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -389,6 +389,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._connection_lock = asyncio.Lock() self._event_listeners: list[Callable[[dict[str, Any]], None]] = [] self._ota_event_listeners: list[Callable[[dict[str, Any]], None]] = [] + self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @@ -426,6 +427,19 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): return _unsubscribe + @callback + def async_subscribe_input_events( + self, input_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to input events.""" + + def _unsubscribe() -> None: + self._input_event_listeners.remove(input_event_callback) + + self._input_event_listeners.append(input_event_callback) + + return _unsubscribe + @callback def async_subscribe_events( self, event_callback: Callable[[dict[str, Any]], None] @@ -469,6 +483,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) self.hass.async_create_task(self._debounced_reload.async_call()) elif event_type in RPC_INPUTS_EVENTS_TYPES: + for event_callback in self._input_event_listeners: + event_callback(event) self.hass.bus.async_fire( EVENT_SHELLY_CLICK, { diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py new file mode 100644 index 00000000000..e37b4cdcdac --- /dev/null +++ b/homeassistant/components/shelly/event.py @@ -0,0 +1,107 @@ +"""Event for Shelly.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from homeassistant.components.event import ( + DOMAIN as EVENT_DOMAIN, + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import RPC_INPUTS_EVENTS_TYPES +from .coordinator import ShellyRpcCoordinator, get_entry_data +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_input_name, + get_rpc_key_instances, + is_rpc_momentary_input, +) + + +@dataclass +class ShellyEventDescription(EventEntityDescription): + """Class to describe Shelly event.""" + + removal_condition: Callable[[dict, dict, str], bool] | None = None + + +RPC_EVENT: Final = ShellyEventDescription( + key="input", + device_class=EventDeviceClass.BUTTON, + event_types=list(RPC_INPUTS_EVENTS_TYPES), + removal_condition=lambda config, status, key: not is_rpc_momentary_input( + config, status, key + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for device.""" + if get_device_entry_gen(config_entry) == 2: + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + + entities = [] + key_instances = get_rpc_key_instances(coordinator.device.status, RPC_EVENT.key) + + for key in key_instances: + if RPC_EVENT.removal_condition and RPC_EVENT.removal_condition( + coordinator.device.config, coordinator.device.status, key + ): + unique_id = f"{coordinator.mac}-{key}" + async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) + else: + entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT)) + + async_add_entities(entities) + + +class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): + """Represent RPC event entity.""" + + _attr_should_poll = False + entity_description: ShellyEventDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + description: ShellyEventDescription, + ) -> None: + """Initialize Shelly entity.""" + super().__init__(coordinator) + self.input_index = int(key.split(":")[-1]) + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + ) + self._attr_unique_id = f"{coordinator.mac}-{key}" + self._attr_name = get_rpc_input_name(coordinator.device, key) + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_input_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: dict[str, Any]) -> None: + """Handle the demo button event.""" + if event["id"] == self.input_index: + self._trigger_event(event["event"]) + self.async_write_ha_state() diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index e78b44db15e..5633f674168 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -285,6 +285,16 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["type"], info["type"])) +def get_rpc_input_name(device: RpcDevice, key: str) -> str: + """Get input name based from the device configuration.""" + input_config = device.config[key] + + if input_name := input_config.get("name"): + return f"{device.name} {input_name}" + + return f"{device.name} {key.replace(':', ' ').capitalize()}" + + def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index e72604260f5..00f88561880 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -191,6 +191,7 @@ MOCK_STATUS_COAP = { MOCK_STATUS_RPC = { "switch:0": {"output": True}, + "input:0": {"id": 0, "state": None}, "light:0": {"output": True, "brightness": 53.0}, "cloud": {"connected": False}, "cover:0": { diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py new file mode 100644 index 00000000000..8222e42408b --- /dev/null +++ b/tests/components/shelly/test_event.py @@ -0,0 +1,70 @@ +"""Tests for Shelly button platform.""" +from __future__ import annotations + +from pytest_unordered import unordered + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN as EVENT_DOMAIN, + EventDeviceClass, +) +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get + +from . import init_integration, inject_rpc_device_event, register_entity + + +async def test_rpc_button(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: + """Test RPC device event.""" + await init_integration(hass, 2) + entity_id = "event.test_name_input_0" + registry = async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["btn_down", "btn_up", "double_push", "long_push", "single_push", "triple_push"] + ) + assert state.attributes.get(ATTR_EVENT_TYPE) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-input:0" + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "single_push", + "id": 0, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_push" + + +async def test_rpc_event_removal( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test RPC event entity is removed due to removal_condition.""" + registry = async_get(hass) + entity_id = register_entity(hass, EVENT_DOMAIN, "test_name_input_0", "input:0") + + assert registry.async_get(entity_id) is not None + + monkeypatch.setitem(mock_rpc_device.config, "input:0", {"id": 0, "type": "switch"}) + await init_integration(hass, 2) + + assert registry.async_get(entity_id) is None diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 1bf660deb2a..a163519c9d1 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -8,6 +8,7 @@ from homeassistant.components.shelly.utils import ( get_device_uptime, get_number_of_channels, get_rpc_channel_name, + get_rpc_input_name, get_rpc_input_triggers, is_block_momentary_input, ) @@ -210,6 +211,18 @@ async def test_get_rpc_channel_name(mock_rpc_device) -> None: assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name switch_3" +async def test_get_rpc_input_name(mock_rpc_device, monkeypatch) -> None: + """Test get RPC input name.""" + assert get_rpc_input_name(mock_rpc_device, "input:0") == "Test name Input 0" + + monkeypatch.setitem( + mock_rpc_device.config, + "input:0", + {"id": 0, "type": "button", "name": "Input name"}, + ) + assert get_rpc_input_name(mock_rpc_device, "input:0") == "Test name Input name" + + async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: """Test get RPC input triggers.""" monkeypatch.setattr(mock_rpc_device, "config", {"input:0": {"type": "button"}}) From 276d245409dd03235399536fd5cee352e2331fcf Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sun, 17 Sep 2023 21:39:23 -0400 Subject: [PATCH 620/984] Bump elkm1-lib to 2.2.6 (#100537) --- homeassistant/components/elkm1/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index ccac1593fa0..3ec5be46d41 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.5"] + "requirements": ["elkm1-lib==2.2.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2329462c46b..2a9f39baf4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -729,7 +729,7 @@ elgato==4.0.1 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.5 +elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c17717ce7af..2bac718dc57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -588,7 +588,7 @@ electrickiwi-api==0.8.5 elgato==4.0.1 # homeassistant.components.elkm1 -elkm1-lib==2.2.5 +elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 From f41e3a2beb315f57a5c96201a86e5f4dea3f035c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 10:52:43 +0200 Subject: [PATCH 621/984] Remove duplicate mobile_app client fixture (#100530) --- tests/components/mobile_app/conftest.py | 24 ++++++--------------- tests/components/mobile_app/test_webhook.py | 4 ++-- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index e7c9ad4995a..f69912f176c 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -10,18 +10,16 @@ from .const import REGISTER, REGISTER_CLEARTEXT @pytest.fixture -async def create_registrations(hass, authed_api_client): +async def create_registrations(hass, webhook_client): """Return two new registrations.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - enc_reg = await authed_api_client.post( - "/api/mobile_app/registrations", json=REGISTER - ) + enc_reg = await webhook_client.post("/api/mobile_app/registrations", json=REGISTER) assert enc_reg.status == HTTPStatus.CREATED enc_reg_json = await enc_reg.json() - clear_reg = await authed_api_client.post( + clear_reg = await webhook_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT ) @@ -34,11 +32,11 @@ async def create_registrations(hass, authed_api_client): @pytest.fixture -async def push_registration(hass, authed_api_client): +async def push_registration(hass, webhook_client): """Return registration with push notifications enabled.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - enc_reg = await authed_api_client.post( + enc_reg = await webhook_client.post( "/api/mobile_app/registrations", json={ **REGISTER, @@ -54,17 +52,7 @@ async def push_registration(hass, authed_api_client): @pytest.fixture -async def webhook_client(hass, authed_api_client, aiohttp_client): - """mobile_app mock client.""" - # We pass in the authed_api_client server instance because - # it is used inside create_registrations and just passing in - # the app instance would cause the server to start twice, - # which caused deprecation warnings to be printed. - return await aiohttp_client(authed_api_client.server) - - -@pytest.fixture -async def authed_api_client(hass, hass_client): +async def webhook_client(hass, hass_client): """Provide an authenticated client for mobile_app to use.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 4faf48e2118..9f6aec404e2 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -196,9 +196,9 @@ async def test_webhook_handle_fire_event( assert events[0].data["hello"] == "yo world" -async def test_webhook_update_registration(webhook_client, authed_api_client) -> None: +async def test_webhook_update_registration(webhook_client) -> None: """Test that a we can update an existing registration via webhook.""" - register_resp = await authed_api_client.post( + register_resp = await webhook_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT ) From 902f997ee020539e0c0d76646e07cc15a76ae362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 18 Sep 2023 12:39:43 +0300 Subject: [PATCH 622/984] Fix google invalid token expiry test init for UTC offsets > 0 (#100533) ``` $ python3 -q >>> import datetime, time >>> time.tzname ('EET', 'EEST') >>> datetime.datetime.max.timestamp() Traceback (most recent call last): File "", line 1, in ValueError: year 10000 is out of range ``` --- tests/components/google/test_init.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 17f300f58cb..233635510e0 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.dt import utcnow +from homeassistant.util.dt import UTC, utcnow from .conftest import ( CALENDAR_ID, @@ -645,7 +645,8 @@ async def test_add_event_location( @pytest.mark.parametrize( - "config_entry_token_expiry", [datetime.datetime.max.timestamp() + 1] + "config_entry_token_expiry", + [datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1], ) async def test_invalid_token_expiry_in_config_entry( hass: HomeAssistant, From 45c0dc68544a7bee4dd5e6f8b2f4f0c5606757f1 Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Mon, 18 Sep 2023 11:44:41 +0200 Subject: [PATCH 623/984] Add missing conversation service translation (#100308) * Update services.yaml * Update strings.json * Update services.yaml * Update strings.json * Update strings.json * fix translation keys * Fix translation keys --- .../components/conversation/services.yaml | 11 +++++++++++ homeassistant/components/conversation/strings.json | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 7b6717eec6d..953db065614 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -14,3 +14,14 @@ process: example: homeassistant selector: conversation_agent: + +reload: + fields: + language: + example: NL + selector: + text: + agent_id: + example: homeassistant + selector: + conversation_agent: diff --git a/homeassistant/components/conversation/strings.json b/homeassistant/components/conversation/strings.json index 15e783c0d90..8240cfa3f82 100644 --- a/homeassistant/components/conversation/strings.json +++ b/homeassistant/components/conversation/strings.json @@ -18,6 +18,20 @@ "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands." } } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads the intent configuration.", + "fields": { + "language": { + "name": "[%key:component::conversation::services::process::fields::language::name%]", + "description": "Language to clear cached intents for. Defaults to server language." + }, + "agent_id": { + "name": "[%key:component::conversation::services::process::fields::agent_id::name%]", + "description": "Conversation agent to reload." + } + } } } } From dc2afb71ae669f537fa9d3a1c85d256f0f561500 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 18 Sep 2023 11:56:28 +0200 Subject: [PATCH 624/984] Move co2signal coordinator to its own file (#100541) * Move co2signal coordinator to its own file * Fix import --- .../components/co2signal/__init__.py | 85 +----------------- .../components/co2signal/config_flow.py | 2 +- .../components/co2signal/coordinator.py | 90 +++++++++++++++++++ .../components/co2signal/diagnostics.py | 3 +- homeassistant/components/co2signal/sensor.py | 2 +- 5 files changed, 97 insertions(+), 85 deletions(-) create mode 100644 homeassistant/components/co2signal/coordinator.py diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 79c56ec63d4..04ae811197b 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -1,25 +1,14 @@ """The CO2 Signal integration.""" from __future__ import annotations -from collections.abc import Mapping -from datetime import timedelta -import logging -from typing import Any, cast - -import CO2Signal - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_COUNTRY_CODE, DOMAIN -from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError -from .models import CO2SignalResponse +from .const import DOMAIN +from .coordinator import CO2SignalCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -35,71 +24,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): - """Data update coordinator.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the coordinator.""" - super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) - ) - self._entry = entry - - @property - def entry_id(self) -> str: - """Return entry ID.""" - return self._entry.entry_id - - async def _async_update_data(self) -> CO2SignalResponse: - """Fetch the latest data from the source.""" - try: - data = await self.hass.async_add_executor_job( - get_data, self.hass, self._entry.data - ) - except InvalidAuth as err: - raise ConfigEntryAuthFailed from err - except CO2Error as err: - raise UpdateFailed(str(err)) from err - - return data - - -def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: - """Get data from the API.""" - if CONF_COUNTRY_CODE in config: - latitude = None - longitude = None - else: - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - try: - data = CO2Signal.get_latest( - config[CONF_API_KEY], - config.get(CONF_COUNTRY_CODE), - latitude, - longitude, - wait=False, - ) - - except ValueError as err: - err_str = str(err) - - if "Invalid authentication credentials" in err_str: - raise InvalidAuth from err - if "API rate limit exceeded." in err_str: - raise APIRatelimitExceeded from err - - _LOGGER.exception("Unexpected exception") - raise UnknownError from err - - if "error" in data: - raise UnknownError(data["error"]) - - if data.get("status") != "ok": - _LOGGER.exception("Unexpected response: %s", data) - raise UnknownError - - return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 2ac3ebc398f..92b09b6e17a 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,8 +10,8 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import get_data from .const import CONF_COUNTRY_CODE, DOMAIN +from .coordinator import get_data from .exceptions import APIRatelimitExceeded, InvalidAuth from .util import get_extra_name diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py new file mode 100644 index 00000000000..2538e913a68 --- /dev/null +++ b/homeassistant/components/co2signal/coordinator.py @@ -0,0 +1,90 @@ +"""DataUpdateCoordinator for the co2signal integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any, cast + +import CO2Signal + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_COUNTRY_CODE, DOMAIN +from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError +from .models import CO2SignalResponse + +PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + + +class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): + """Data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) + ) + self._entry = entry + + @property + def entry_id(self) -> str: + """Return entry ID.""" + return self._entry.entry_id + + async def _async_update_data(self) -> CO2SignalResponse: + """Fetch the latest data from the source.""" + try: + data = await self.hass.async_add_executor_job( + get_data, self.hass, self._entry.data + ) + except InvalidAuth as err: + raise ConfigEntryAuthFailed from err + except CO2Error as err: + raise UpdateFailed(str(err)) from err + + return data + + +def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: + """Get data from the API.""" + if CONF_COUNTRY_CODE in config: + latitude = None + longitude = None + else: + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + try: + data = CO2Signal.get_latest( + config[CONF_API_KEY], + config.get(CONF_COUNTRY_CODE), + latitude, + longitude, + wait=False, + ) + + except ValueError as err: + err_str = str(err) + + if "Invalid authentication credentials" in err_str: + raise InvalidAuth from err + if "API rate limit exceeded." in err_str: + raise APIRatelimitExceeded from err + + _LOGGER.exception("Unexpected exception") + raise UnknownError from err + + if "error" in data: + raise UnknownError(data["error"]) + + if data.get("status") != "ok": + _LOGGER.exception("Unexpected response: %s", data) + raise UnknownError + + return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/diagnostics.py b/homeassistant/components/co2signal/diagnostics.py index 8ab09b8cb75..db08aa4eca6 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -8,7 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from . import DOMAIN, CO2SignalCoordinator +from .const import DOMAIN +from .coordinator import CO2SignalCoordinator TO_REDACT = {CONF_API_KEY} diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index c5bc7eb4c20..d00bdf70d3e 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -17,8 +17,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import CO2SignalCoordinator from .const import ATTRIBUTION, DOMAIN +from .coordinator import CO2SignalCoordinator SCAN_INTERVAL = timedelta(minutes=3) From 08c4e82cf95341580fc707bc04ae6854fd3d7671 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 11:58:47 +0200 Subject: [PATCH 625/984] Update typing-extensions to 4.8.0 (#100545) --- 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 5aa3a010d64..df72b224c63 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.15 -typing-extensions>=4.7.0,<5.0 +typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 diff --git a/pyproject.toml b/pyproject.toml index bfc3472651c..28c60e98269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", - "typing-extensions>=4.7.0,<5.0", + "typing-extensions>=4.8.0,<5.0", "ulid-transform==0.8.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", diff --git a/requirements.txt b/requirements.txt index 2f6024a2e6a..40f7584ca31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 -typing-extensions>=4.7.0,<5.0 +typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 From 306f39b0535e15d50898b0e2815050a271e004b8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 12:26:16 +0200 Subject: [PATCH 626/984] Update pytest warnings filter (#100546) --- pyproject.toml | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 28c60e98269..a50ef040927 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -451,12 +451,6 @@ filterwarnings = [ # -- tracked upstream / open PRs # https://github.com/caronc/apprise/issues/659 - v1.4.5 "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", - # https://github.com/gwww/elkm1/pull/71 - v2.2.5 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/poljar/matrix-nio/pull/438 - v0.21.2 - "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", - # https://github.com/poljar/matrix-nio/pull/439 - v0.21.2 - "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 "ignore:'imghdr' is deprecated and slated for removal in Python 3.13:DeprecationWarning:mediafile", # https://github.com/eclipse/paho.mqtt.python/issues/653 - v1.6.1 @@ -466,8 +460,6 @@ filterwarnings = [ "ignore:the imp module is deprecated in favour of importlib and slated for removal in Python 3.12:DeprecationWarning:future.standard_library", # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.2 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", - # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - v0.5.3 - "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 # Should resolve itself once pytest-xdist 4.0 is released and the option is removed "ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning:xdist.plugin", @@ -477,10 +469,12 @@ filterwarnings = [ "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", - # https://github.com/gurumitts/pylutron-caseta/pull/143 - >0.18.1 - "ignore:ssl.PROTOCOL_TLSv1_2 is deprecated:DeprecationWarning:pylutron_caseta.smartbridge", - # https://github.com/Danielhiversen/pyMillLocal/pull/8 - >=0.3.0 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:mill_local", + # https://github.com/poljar/matrix-nio/pull/438 - >0.21.2 + "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", + # https://github.com/poljar/matrix-nio/pull/439 - >0.21.2 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", + # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 + "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 @@ -490,6 +484,9 @@ filterwarnings = [ # Locale changes might take some time to resolve upstream "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:homematicip.base.base_connection", "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:micloud.micloud", + # Wrong stacklevel + # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 + "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 @@ -505,6 +502,7 @@ filterwarnings = [ # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 # https://github.com/vaidik/commentjson/issues/51 + # https://github.com/vaidik/commentjson/pull/52 # Fixed upstream, commentjson depends on old version and seems to be unmaintained "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 From 6ac1305c6475ac8fed5585951784673edc40ba2e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 18 Sep 2023 12:39:09 +0200 Subject: [PATCH 627/984] Adjust codeowners in modbus (#100474) --- CODEOWNERS | 4 ++-- homeassistant/components/modbus/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8453a4893fe..a2413c2e720 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -773,8 +773,8 @@ build.json @home-assistant/supervisor /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core -/homeassistant/components/modbus/ @adamchengtkc @janiversen @vzahradnik -/tests/components/modbus/ @adamchengtkc @janiversen @vzahradnik +/homeassistant/components/modbus/ @janiversen +/tests/components/modbus/ @janiversen /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index b70055e5fbe..7faf873b655 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,7 +1,7 @@ { "domain": "modbus", "name": "Modbus", - "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"], + "codeowners": ["@janiversen"], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], From ec6c374761b00482fd6b4197c28298534ff693d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 18 Sep 2023 12:42:31 +0200 Subject: [PATCH 628/984] Clean up lyric sensor platform (#100495) * Clean up lyric sensor platform * Clean up lyric sensor platform * Clean up lyric sensor platform * Update homeassistant/components/lyric/sensor.py Co-authored-by: Aidan Timson * Update homeassistant/components/lyric/sensor.py Co-authored-by: Aidan Timson * Update homeassistant/components/lyric/sensor.py Co-authored-by: Aidan Timson * Update homeassistant/components/lyric/sensor.py Co-authored-by: Aidan Timson --------- Co-authored-by: Aidan Timson --- homeassistant/components/lyric/sensor.py | 206 ++++++++++------------- 1 file changed, 88 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index d628a108183..5bab1ffeb6f 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import cast from aiolyric import Lyric from aiolyric.objects.device import LyricDevice @@ -43,10 +42,84 @@ LYRIC_SETPOINT_STATUS_NAMES = { @dataclass -class LyricSensorEntityDescription(SensorEntityDescription): +class LyricSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[LyricDevice], StateType | datetime] + suitable_fn: Callable[[LyricDevice], bool] + + +@dataclass +class LyricSensorEntityDescription( + SensorEntityDescription, LyricSensorEntityDescriptionMixin +): """Class describing Honeywell Lyric sensor entities.""" - value: Callable[[LyricDevice], StateType | datetime] = round + +DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ + LyricSensorEntityDescription( + key="indoor_temperature", + translation_key="indoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.indoorTemperature, + suitable_fn=lambda device: device.indoorTemperature, + ), + LyricSensorEntityDescription( + key="indoor_humidity", + translation_key="indoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.indoorHumidity, + suitable_fn=lambda device: device.indoorHumidity, + ), + LyricSensorEntityDescription( + key="outdoor_temperature", + translation_key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.outdoorTemperature, + suitable_fn=lambda device: device.outdoorTemperature, + ), + LyricSensorEntityDescription( + key="outdoor_humidity", + translation_key="outdoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.displayedOutdoorHumidity, + suitable_fn=lambda device: device.displayedOutdoorHumidity, + ), + LyricSensorEntityDescription( + key="next_period_time", + translation_key="next_period_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: get_datetime_from_future_time( + device.changeableValues.nextPeriodTime + ), + suitable_fn=lambda device: device.changeableValues + and device.changeableValues.nextPeriodTime, + ), + LyricSensorEntityDescription( + key="setpoint_status", + translation_key="setpoint_status", + icon="mdi:thermostat", + value_fn=lambda device: get_setpoint_status( + device.changeableValues.thermostatSetpointStatus, + device.changeableValues.nextPeriodTime, + ), + suitable_fn=lambda device: device.changeableValues + and device.changeableValues.thermostatSetpointStatus, + ), +] + + +def get_setpoint_status(status: str, time: str) -> str | None: + """Get status of the setpoint.""" + if status == PRESET_HOLD_UNTIL: + return f"Held until {time}" + return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) def get_datetime_from_future_time(time_str: str) -> datetime: @@ -68,129 +141,25 @@ async def async_setup_entry( entities = [] - def get_setpoint_status(status: str, time: str) -> str | None: - if status == PRESET_HOLD_UNTIL: - return f"Held until {time}" - return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) - for location in coordinator.data.locations: for device in location.devices: - if device.indoorTemperature: - if device.units == "Fahrenheit": - native_temperature_unit = UnitOfTemperature.FAHRENHEIT - else: - native_temperature_unit = UnitOfTemperature.CELSIUS - - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_indoor_temperature", - translation_key="indoor_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=native_temperature_unit, - value=lambda device: device.indoorTemperature, - ), - location, - device, - ) - ) - if device.indoorHumidity: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_indoor_humidity", - translation_key="indoor_humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - value=lambda device: device.indoorHumidity, - ), - location, - device, - ) - ) - if device.outdoorTemperature: - if device.units == "Fahrenheit": - native_temperature_unit = UnitOfTemperature.FAHRENHEIT - else: - native_temperature_unit = UnitOfTemperature.CELSIUS - - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_outdoor_temperature", - translation_key="outdoor_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=native_temperature_unit, - value=lambda device: device.outdoorTemperature, - ), - location, - device, - ) - ) - if device.displayedOutdoorHumidity: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_outdoor_humidity", - translation_key="outdoor_humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - value=lambda device: device.displayedOutdoorHumidity, - ), - location, - device, - ) - ) - if device.changeableValues: - if device.changeableValues.nextPeriodTime: + for device_sensor in DEVICE_SENSORS: + if device_sensor.suitable_fn(device): entities.append( LyricSensor( coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_next_period_time", - translation_key="next_period_time", - device_class=SensorDeviceClass.TIMESTAMP, - value=lambda device: get_datetime_from_future_time( - device.changeableValues.nextPeriodTime - ), - ), - location, - device, - ) - ) - if device.changeableValues.thermostatSetpointStatus: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_setpoint_status", - translation_key="setpoint_status", - icon="mdi:thermostat", - value=lambda device: get_setpoint_status( - device.changeableValues.thermostatSetpointStatus, - device.changeableValues.nextPeriodTime, - ), - ), + device_sensor, location, device, ) ) - async_add_entities(entities, True) + async_add_entities(entities) class LyricSensor(LyricDeviceEntity, SensorEntity): """Define a Honeywell Lyric sensor.""" - coordinator: DataUpdateCoordinator[Lyric] entity_description: LyricSensorEntityDescription def __init__( @@ -205,15 +174,16 @@ class LyricSensor(LyricDeviceEntity, SensorEntity): coordinator, location, device, - description.key, + f"{device.macID}_{description.key}", ) self.entity_description = description + if description.device_class == SensorDeviceClass.TEMPERATURE: + if device.units == "Fahrenheit": + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + else: + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state.""" - device: LyricDevice = self.device - try: - return cast(StateType, self.entity_description.value(device)) - except TypeError: - return None + return self.entity_description.value_fn(self.device) From adf34bdf8bcedc5dbfe0d1d1eafe71875cf7a29a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 18 Sep 2023 12:56:35 +0200 Subject: [PATCH 629/984] Set co2signal integration type to service (#100543) --- homeassistant/components/co2signal/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 4ab4607cccc..a4d7c55d6da 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["CO2Signal"], "requirements": ["CO2Signal==0.4.2"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a65239316ed..9fcb5389415 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -863,7 +863,7 @@ }, "co2signal": { "name": "Electricity Maps", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, From 49d742ce318efc7b1b50e0f045d7341165a36683 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Sep 2023 10:08:38 -0500 Subject: [PATCH 630/984] Drop codeowner for Magic Home/flux_led (#100557) --- CODEOWNERS | 4 ++-- homeassistant/components/flux_led/manifest.json | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index a2413c2e720..fd18e096b91 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -400,8 +400,8 @@ build.json @home-assistant/supervisor /tests/components/flo/ @dmulcahey /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor -/homeassistant/components/flux_led/ @icemanch @bdraco -/tests/components/flux_led/ @icemanch @bdraco +/homeassistant/components/flux_led/ @icemanch +/tests/components/flux_led/ @icemanch /homeassistant/components/forecast_solar/ @klaasnicolaas @frenck /tests/components/forecast_solar/ @klaasnicolaas @frenck /homeassistant/components/forked_daapd/ @uvjustin diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 977f6eefe07..a55ae028342 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -1,7 +1,7 @@ { "domain": "flux_led", "name": "Magic Home", - "codeowners": ["@icemanch", "@bdraco"], + "codeowners": ["@icemanch"], "config_flow": true, "dependencies": ["network"], "dhcp": [ @@ -53,6 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "quality_scale": "platinum", "requirements": ["flux-led==1.0.4"] } From fa1a1715c97844181a4eb7d5bc3f417cca0cbb42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Sep 2023 10:08:49 -0500 Subject: [PATCH 631/984] Drop codeowner for LIFX (#100556) --- CODEOWNERS | 2 -- homeassistant/components/lifx/manifest.json | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index fd18e096b91..e985b6f20b4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,8 +688,6 @@ build.json @home-assistant/supervisor /tests/components/lidarr/ @tkdrob /homeassistant/components/life360/ @pnbruckner /tests/components/life360/ @pnbruckner -/homeassistant/components/lifx/ @bdraco -/tests/components/lifx/ @bdraco /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core /homeassistant/components/linux_battery/ @fabaff diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index d6b253bd478..7cabfd4712f 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -1,7 +1,7 @@ { "domain": "lifx", "name": "LIFX", - "codeowners": ["@bdraco"], + "codeowners": [], "config_flow": true, "dependencies": ["network"], "dhcp": [ @@ -39,7 +39,6 @@ }, "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], - "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.10", "aiolifx-effects==0.3.2", From ddd62a8f63bbddb456981842096d5116a54f2926 Mon Sep 17 00:00:00 2001 From: rappenze Date: Mon, 18 Sep 2023 20:22:23 +0200 Subject: [PATCH 632/984] Fibaro streamline hass.data entry (#100547) * Fibaro streamline hass.data entry * Fix tests --- homeassistant/components/fibaro/__init__.py | 11 ++--------- homeassistant/components/fibaro/binary_sensor.py | 7 +++---- homeassistant/components/fibaro/climate.py | 7 +++---- homeassistant/components/fibaro/cover.py | 10 +++------- homeassistant/components/fibaro/light.py | 10 +++------- homeassistant/components/fibaro/lock.py | 10 +++------- homeassistant/components/fibaro/scene.py | 10 +++------- homeassistant/components/fibaro/sensor.py | 8 +++++--- homeassistant/components/fibaro/switch.py | 10 +++------- tests/components/fibaro/conftest.py | 10 +++------- 10 files changed, 31 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 86f25253c2d..ffa13749fa7 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -35,8 +35,6 @@ from .const import CONF_IMPORT_PLUGINS, DOMAIN _LOGGER = logging.getLogger(__name__) -FIBARO_CONTROLLER = "fibaro_controller" -FIBARO_DEVICES = "fibaro_devices" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -377,12 +375,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except FibaroAuthFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex - data: dict[str, Any] = {} - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data - data[FIBARO_CONTROLLER] = controller - devices = data[FIBARO_DEVICES] = {} - for platform in PLATFORMS: - devices[platform] = [*controller.fibaro_devices[platform]] + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller # register the hub device info separately as the hub has sometimes no entities device_registry = dr.async_get(hass) @@ -408,7 +401,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id][FIBARO_CONTROLLER].disable_state_handler() + hass.data[DOMAIN][entry.entry_id].disable_state_handler() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 57b3bc99b4f..07c0d9a779c 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN SENSOR_TYPES = { @@ -45,12 +45,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ FibaroBinarySensor(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.BINARY_SENSOR - ] + for device in controller.fibaro_devices[Platform.BINARY_SENSOR] ], True, ) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index a56056ade03..18fef8dbe7a 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN PRESET_RESUME = "resume" @@ -113,12 +113,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ FibaroThermostat(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.CLIMATE - ] + for device in controller.fibaro_devices[Platform.CLIMATE] ], True, ) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index c73c45d254c..d353b352c5c 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -17,7 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -27,13 +27,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro covers.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroCover(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.COVER - ] - ], + [FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]], True, ) diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 6a918f64f86..981b81fdd43 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -23,7 +23,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN PARALLEL_UPDATES = 2 @@ -56,13 +56,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroLight(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.LIGHT - ] - ], + [FibaroLight(device) for device in controller.fibaro_devices[Platform.LIGHT]], True, ) diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 503407bc28f..715116d2843 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -11,7 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -21,13 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro locks.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroLock(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.LOCK - ] - ], + [FibaroLock(device) for device in controller.fibaro_devices[Platform.LOCK]], True, ) diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 812a85b2f50..36d2666f97d 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify -from . import FIBARO_DEVICES, FibaroController +from . import FibaroController from .const import DOMAIN @@ -23,13 +23,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro scenes.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroScene(scene) - for scene in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.SCENE - ] - ], + [FibaroScene(scene) for scene in controller.fibaro_devices[Platform.SCENE]], True, ) diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index b98e12b889e..e859a9b1afb 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import convert -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN # List of known sensors which represents a fibaro device @@ -107,7 +107,9 @@ async def async_setup_entry( """Set up the Fibaro controller devices.""" entities: list[SensorEntity] = [] - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][Platform.SENSOR]: + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + + for device in controller.fibaro_devices[Platform.SENSOR]: entity_description = MAIN_SENSOR_TYPES.get(device.type) # main sensors are created even if the entity type is not known @@ -122,7 +124,7 @@ async def async_setup_entry( Platform.SENSOR, Platform.SWITCH, ): - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][platform]: + for device in controller.fibaro_devices[platform]: for entity_description in ADDITIONAL_SENSOR_TYPES: if entity_description.key in device.properties: entities.append(FibaroAdditionalSensor(device, entity_description)) diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 6ca770ab2d1..fdd473ea282 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -11,7 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -21,13 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro switches.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroSwitch(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.SWITCH - ] - ], + [FibaroSwitch(device) for device in controller.fibaro_devices[Platform.SWITCH]], True, ) diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 8a2bbcbcd4a..1a3f9b083b8 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch from pyfibaro.fibaro_scene import SceneModel import pytest -from homeassistant.components.fibaro import DOMAIN, FIBARO_CONTROLLER, FIBARO_DEVICES +from homeassistant.components.fibaro import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -47,16 +47,12 @@ async def setup_platform( controller_mock = Mock() controller_mock.hub_serial = "HC2-111111" controller_mock.get_room_name.return_value = room_name + controller_mock.fibaro_devices = {Platform.SCENE: scenes} for scene in scenes: scene.fibaro_controller = controller_mock - hass.data[DOMAIN] = { - config_entry.entry_id: { - FIBARO_CONTROLLER: controller_mock, - FIBARO_DEVICES: {Platform.SCENE: scenes}, - } - } + hass.data[DOMAIN] = {config_entry.entry_id: controller_mock} await hass.config_entries.async_forward_entry_setup(config_entry, platform) await hass.async_block_till_done() return config_entry From 37288d7788988bff9a09b55a05a4b678c0e6f6d5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 20:39:36 +0200 Subject: [PATCH 633/984] Add pylint plugin to check for calls to base implementation (#100432) --- .../components/airvisual/__init__.py | 1 + homeassistant/components/flo/switch.py | 1 + .../hunterdouglas_powerview/cover.py | 1 + .../hunterdouglas_powerview/sensor.py | 1 + .../hvv_departures/binary_sensor.py | 1 + homeassistant/components/isy994/sensor.py | 1 + homeassistant/components/isy994/switch.py | 1 + homeassistant/components/livisi/entity.py | 1 + .../components/lutron_caseta/binary_sensor.py | 1 + homeassistant/components/rflink/sensor.py | 1 + homeassistant/components/risco/entity.py | 1 + homeassistant/components/risco/sensor.py | 1 + homeassistant/components/shelly/entity.py | 2 + .../components/smart_meter_texas/sensor.py | 1 + .../components/tractive/device_tracker.py | 1 + pylint/plugins/hass_enforce_super_call.py | 79 +++++++ pyproject.toml | 1 + tests/pylint/conftest.py | 46 ++-- tests/pylint/test_enforce_super_call.py | 221 ++++++++++++++++++ 19 files changed, 349 insertions(+), 14 deletions(-) create mode 100644 pylint/plugins/hass_enforce_super_call.py create mode 100644 tests/pylint/test_enforce_super_call.py diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 21be2e5d664..8860db69b79 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -421,6 +421,7 @@ class AirVisualEntity(CoordinatorEntity): self._entry = entry self.entity_description = description + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 18a4341db57..4456732d125 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -100,6 +100,7 @@ class FloSwitch(FloEntity, SwitchEntity): self._attr_is_on = self._device.last_known_valve_state == "open" self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove(self._device.async_add_listener(self.async_update_state)) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 833c1812ddb..18fe1cd0a69 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -311,6 +311,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): await self.async_update() self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 825ca140f14..330e5dddfa5 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -136,6 +136,7 @@ class PowerViewSensor(ShadeEntity, SensorEntity): """Get the current value in percentage.""" return self.entity_description.native_value_fn(self._shade) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 513c8dbd8b0..2eeb6339214 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -192,6 +192,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): if v is not None } + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index b1899100dd4..1a160024a65 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -262,6 +262,7 @@ class ISYAuxSensorEntity(ISYSensorEntity): """Return the target value.""" return None if self.target is None else self.target.value + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events. diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 8467cba9e6a..de64741ba3a 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -156,6 +156,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity): self._attr_name = description.name # Override super self._change_handler: EventListener = None + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events.""" self._change_handler = self._node.isy.nodes.status_events.subscribe( diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 5ddba1e2e86..388788d3dea 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -64,6 +64,7 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): ) super().__init__(coordinator) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callback for reachability.""" self.async_on_remove( diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 334590c0e65..da7d6106796 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -63,6 +63,7 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): """Return the brightness of the light.""" return self._device["status"] == OCCUPANCY_GROUP_OCCUPIED + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" self._smartbridge.add_occupancy_subscriber( diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index b96e03e7eb4..fd6db8f0c60 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -352,6 +352,7 @@ class RflinkSensor(RflinkDevice, SensorEntity): """Domain specific event handler.""" self._state = event["value"] + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register update callback.""" # Remove temporary bogus entity_id if added diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index 7f8e3be698b..f8869d75d4b 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -35,6 +35,7 @@ class RiscoCloudEntity(CoordinatorEntity[RiscoDataUpdateCoordinator]): self._get_data_from_coordinator() self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index bb416b8c550..b196723afbe 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -86,6 +86,7 @@ class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEnt self._attr_name = f"Risco {self.coordinator.risco.site_name} {name} Events" self._attr_device_class = SensorDeviceClass.TIMESTAMP + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self._entity_registry = er.async_get(self.hass) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 69dc6cb9340..5afa5f8b727 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -332,6 +332,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) @@ -375,6 +376,7 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Device status by entity key.""" return cast(dict, self.coordinator.device.status[self.key]) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index d237daf01ca..84ad68fabc3 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -73,6 +73,7 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): self._attr_native_value = self.meter.reading self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self): """Subscribe to updates.""" self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 0e373e1a44f..00296f3108c 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -99,6 +99,7 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): self._attr_available = True self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" if not self._client.subscribed: diff --git a/pylint/plugins/hass_enforce_super_call.py b/pylint/plugins/hass_enforce_super_call.py new file mode 100644 index 00000000000..db4b2d4a5d7 --- /dev/null +++ b/pylint/plugins/hass_enforce_super_call.py @@ -0,0 +1,79 @@ +"""Plugin for checking super calls.""" +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.interfaces import INFERENCE +from pylint.lint import PyLinter + +METHODS = { + "async_added_to_hass", +} + + +class HassEnforceSuperCallChecker(BaseChecker): # type: ignore[misc] + """Checker for super calls.""" + + name = "hass_enforce_super_call" + priority = -1 + msgs = { + "W7441": ( + "Missing call to: super().%s", + "hass-missing-super-call", + "Used when method should call its parent implementation.", + ), + } + options = () + + def visit_functiondef( + self, node: nodes.FunctionDef | nodes.AsyncFunctionDef + ) -> None: + """Check for super calls in method body.""" + if node.name not in METHODS: + return + + assert node.parent + parent = node.parent.frame() + if not isinstance(parent, nodes.ClassDef): + return + + # Check function body for super call + for child_node in node.body: + while isinstance(child_node, (nodes.Expr, nodes.Await, nodes.Return)): + child_node = child_node.value + match child_node: + case nodes.Call( + func=nodes.Attribute( + expr=nodes.Call(func=nodes.Name(name="super")), + attrname=node.name, + ), + ): + return + + # Check for non-empty base implementation + found_base_implementation = False + for base in parent.ancestors(): + for method in base.mymethods(): + if method.name != node.name: + continue + if method.body and not ( + len(method.body) == 1 and isinstance(method.body[0], nodes.Pass) + ): + found_base_implementation = True + break + + if found_base_implementation: + self.add_message( + "hass-missing-super-call", + node=node, + args=(node.name,), + confidence=INFERENCE, + ) + break + + visit_asyncfunctiondef = visit_functiondef + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceSuperCallChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index a50ef040927..7dfd584c598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_enforce_super_call", "hass_enforce_type_hints", "hass_inheritance", "hass_imports", diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 4a53f686c5a..03f637a646f 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -11,13 +11,11 @@ import pytest BASE_PATH = Path(__file__).parents[2] -@pytest.fixture(name="hass_enforce_type_hints", scope="session") -def hass_enforce_type_hints_fixture() -> ModuleType: - """Fixture to provide a requests mocker.""" - module_name = "hass_enforce_type_hints" +def _load_plugin_from_file(module_name: str, file: str) -> ModuleType: + """Load plugin from file path.""" spec = spec_from_file_location( module_name, - str(BASE_PATH.joinpath("pylint/plugins/hass_enforce_type_hints.py")), + str(BASE_PATH.joinpath(file)), ) assert spec and spec.loader @@ -27,6 +25,15 @@ def hass_enforce_type_hints_fixture() -> ModuleType: return module +@pytest.fixture(name="hass_enforce_type_hints", scope="session") +def hass_enforce_type_hints_fixture() -> ModuleType: + """Fixture to provide a requests mocker.""" + return _load_plugin_from_file( + "hass_enforce_type_hints", + "pylint/plugins/hass_enforce_type_hints.py", + ) + + @pytest.fixture(name="linter") def linter_fixture() -> UnittestLinter: """Fixture to provide a requests mocker.""" @@ -44,16 +51,10 @@ def type_hint_checker_fixture(hass_enforce_type_hints, linter) -> BaseChecker: @pytest.fixture(name="hass_imports", scope="session") def hass_imports_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - module_name = "hass_imports" - spec = spec_from_file_location( - module_name, str(BASE_PATH.joinpath("pylint/plugins/hass_imports.py")) + return _load_plugin_from_file( + "hass_imports", + "pylint/plugins/hass_imports.py", ) - assert spec and spec.loader - - module = module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module @pytest.fixture(name="imports_checker") @@ -62,3 +63,20 @@ def imports_checker_fixture(hass_imports, linter) -> BaseChecker: type_hint_checker = hass_imports.HassImportsFormatChecker(linter) type_hint_checker.module = "homeassistant.components.pylint_test" return type_hint_checker + + +@pytest.fixture(name="hass_enforce_super_call", scope="session") +def hass_enforce_super_call_fixture() -> ModuleType: + """Fixture to provide a requests mocker.""" + return _load_plugin_from_file( + "hass_enforce_super_call", + "pylint/plugins/hass_enforce_super_call.py", + ) + + +@pytest.fixture(name="super_call_checker") +def super_call_checker_fixture(hass_enforce_super_call, linter) -> BaseChecker: + """Fixture to provide a requests mocker.""" + super_call_checker = hass_enforce_super_call.HassEnforceSuperCallChecker(linter) + super_call_checker.module = "homeassistant.components.pylint_test" + return super_call_checker diff --git a/tests/pylint/test_enforce_super_call.py b/tests/pylint/test_enforce_super_call.py new file mode 100644 index 00000000000..5e2861b1c74 --- /dev/null +++ b/tests/pylint/test_enforce_super_call.py @@ -0,0 +1,221 @@ +"""Tests for pylint hass_enforce_super_call plugin.""" +from __future__ import annotations + +from types import ModuleType +from unittest.mock import patch + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import INFERENCE +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_adds_messages, assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + pass + """, + id="no_parent", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 2 + """, + id="empty_parent_implementation", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + pass + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 2 + """, + id="empty_parent_implementation2", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + """, + id="correct_super_call", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + return await super().async_added_to_hass() + """, + id="super_call_in_return", + ), + pytest.param( + """ + class Entity: + def added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + def added_to_hass(self) -> None: + super().added_to_hass() + """, + id="super_call_not_async", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + """, + id="multiple_inheritance", + ), + pytest.param( + """ + async def async_added_to_hass() -> None: + x = 2 + """, + id="not_a_method", + ), + ], +) +def test_enforce_super_call( + linter: UnittestLinter, + hass_enforce_super_call: ModuleType, + super_call_checker: BaseChecker, + code: str, +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(super_call_checker) + + with patch.object( + hass_enforce_super_call, "METHODS", new={"added_to_hass", "async_added_to_hass"} + ), assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + ("code", "node_idx"), + [ + pytest.param( + """ + class Entity: + def added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + def added_to_hass(self) -> None: + x = 3 + """, + 1, + id="no_super_call", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 3 + """, + 1, + id="no_super_call_async", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + await Entity.async_added_to_hass() + """, + 1, + id="explicit_call_to_base_implementation", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_added_to_hass(self) -> None: + x = 3 + """, + 2, + id="multiple_inheritance", + ), + ], +) +def test_enforce_super_call_bad( + linter: UnittestLinter, + hass_enforce_super_call: ModuleType, + super_call_checker: BaseChecker, + code: str, + node_idx: int, +) -> None: + """Bad test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(super_call_checker) + node = root_node.body[node_idx].body[0] + + with patch.object( + hass_enforce_super_call, "METHODS", new={"added_to_hass", "async_added_to_hass"} + ), assert_adds_messages( + linter, + MessageTest( + msg_id="hass-missing-super-call", + node=node, + line=node.lineno, + args=(node.name,), + col_offset=node.col_offset, + end_line=node.position.end_lineno, + end_col_offset=node.position.end_col_offset, + confidence=INFERENCE, + ), + ): + walker.walk(root_node) From 2722e5ddaaf583adda9dce55197145b54097d08d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 18 Sep 2023 21:31:04 +0200 Subject: [PATCH 634/984] Add Vodafone Station sensor platform (#99948) * Vodafone: add sensor platform * fix for model VOX30 v1 * fix, cleanup, 2 new sensors * apply review comments * apply last review comment --- .coveragerc | 1 + .../components/vodafone_station/__init__.py | 2 +- .../components/vodafone_station/const.py | 3 +- .../components/vodafone_station/sensor.py | 217 ++++++++++++++++++ .../components/vodafone_station/strings.json | 25 ++ 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/vodafone_station/sensor.py diff --git a/.coveragerc b/.coveragerc index e226b22381b..308546a5ebb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1462,6 +1462,7 @@ omit = homeassistant/components/vodafone_station/const.py homeassistant/components/vodafone_station/coordinator.py homeassistant/components/vodafone_station/device_tracker.py + homeassistant/components/vodafone_station/sensor.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/__init__.py homeassistant/components/volumio/browse_media.py diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index c1cf23d974f..cf2a22d2dbc 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import VodafoneStationRouter -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py index 8d5a60afb60..c4828e19951 100644 --- a/homeassistant/components/vodafone_station/const.py +++ b/homeassistant/components/vodafone_station/const.py @@ -8,4 +8,5 @@ DOMAIN = "vodafone_station" DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.1.1" DEFAULT_USERNAME = "vodafone" -DEFAULT_SSL = True + +LINE_TYPES = ["dsl", "fiber", "internet_key"] diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py new file mode 100644 index 00000000000..0ca705ad56b --- /dev/null +++ b/homeassistant/components/vodafone_station/sensor.py @@ -0,0 +1,217 @@ +"""Vodafone Station sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate +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 homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import utcnow + +from .const import _LOGGER, DOMAIN, LINE_TYPES +from .coordinator import VodafoneStationRouter + +NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] + + +@dataclass +class VodafoneStationBaseEntityDescription: + """Vodafone Station entity base description.""" + + value: Callable[[Any, Any], Any] = lambda val, key: val[key] + is_suitable: Callable[[dict], bool] = lambda val: True + + +@dataclass +class VodafoneStationEntityDescription( + VodafoneStationBaseEntityDescription, SensorEntityDescription +): + """Vodafone Station entity description.""" + + +def _calculate_uptime(value: dict, key: str) -> datetime: + """Calculate device uptime.""" + d = int(value[key].split(":")[0]) + h = int(value[key].split(":")[1]) + m = int(value[key].split(":")[2]) + + return utcnow() - timedelta(days=d, hours=h, minutes=m) + + +def _line_connection(value: dict, key: str) -> str | None: + """Identify line type.""" + + internet_ip = value[key] + dsl_ip = value.get("dsl_ipaddr") + fiber_ip = value.get("fiber_ipaddr") + internet_key_ip = value.get("vf_internet_key_ip_addr") + + if internet_ip == dsl_ip: + return LINE_TYPES[0] + + if internet_ip == fiber_ip: + return LINE_TYPES[1] + + if internet_ip == internet_key_ip: + return LINE_TYPES[2] + + return None + + +SENSOR_TYPES: Final = ( + VodafoneStationEntityDescription( + key="wan_ip4_addr", + translation_key="external_ipv4", + icon="mdi:earth", + is_suitable=lambda info: info["wan_ip4_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="wan_ip6_addr", + translation_key="external_ipv6", + icon="mdi:earth", + is_suitable=lambda info: info["wan_ip6_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="vf_internet_key_ip_addr", + translation_key="external_ip_key", + icon="mdi:earth", + is_suitable=lambda info: info["vf_internet_key_ip_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="inter_ip_address", + translation_key="active_connection", + device_class=SensorDeviceClass.ENUM, + icon="mdi:wan", + options=LINE_TYPES, + value=_line_connection, + ), + VodafoneStationEntityDescription( + key="down_str", + translation_key="down_stream", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="up_str", + translation_key="up_stream", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="fw_version", + translation_key="fw_version", + icon="mdi:new-box", + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="phone_num1", + translation_key="phone_num1", + icon="mdi:phone", + is_suitable=lambda info: info["phone_unavailable1"] == "0", + ), + VodafoneStationEntityDescription( + key="phone_num2", + translation_key="phone_num2", + icon="mdi:phone", + is_suitable=lambda info: info["phone_unavailable2"] == "0", + ), + VodafoneStationEntityDescription( + key="sys_uptime", + translation_key="sys_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value=_calculate_uptime, + ), + VodafoneStationEntityDescription( + key="sys_cpu_usage", + translation_key="sys_cpu_usage", + icon="mdi:chip", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda value, key: float(value[key][:-1]), + ), + VodafoneStationEntityDescription( + key="sys_memory_usage", + translation_key="sys_memory_usage", + icon="mdi:memory", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda value, key: float(value[key][:-1]), + ), + VodafoneStationEntityDescription( + key="sys_reboot_cause", + translation_key="sys_reboot_cause", + icon="mdi:restart-alert", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entry.""" + _LOGGER.debug("Setting up Vodafone Station sensors") + + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + + sensors_data = coordinator.data.sensors + + async_add_entities( + VodafoneStationSensorEntity(coordinator, sensor_descr) + for sensor_descr in SENSOR_TYPES + if sensor_descr.key in sensors_data and sensor_descr.is_suitable(sensors_data) + ) + + +class VodafoneStationSensorEntity( + CoordinatorEntity[VodafoneStationRouter], SensorEntity +): + """Representation of a Vodafone Station sensor.""" + + _attr_has_entity_name = True + entity_description: VodafoneStationEntityDescription + + def __init__( + self, + coordinator: VodafoneStationRouter, + description: VodafoneStationEntityDescription, + ) -> None: + """Initialize a Vodafone Station sensor.""" + super().__init__(coordinator) + + sensors_data = coordinator.data.sensors + serial_num = sensors_data["sys_serial_number"] + self.entity_description = description + + self._attr_device_info = DeviceInfo( + configuration_url=coordinator.api.base_url, + identifiers={(DOMAIN, serial_num)}, + name=f"Vodafone Station ({serial_num})", + manufacturer="Vodafone", + model=sensors_data.get("sys_model_name"), + hw_version=sensors_data["sys_hardware_version"], + sw_version=sensors_data["sys_firmware_version"], + ) + self._attr_unique_id = f"{serial_num}_{description.key}" + + @property + def native_value(self) -> StateType: + """Sensor value.""" + return self.entity_description.value( + self.coordinator.data.sensors, self.entity_description.key + ) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 3c452133c28..0c2a4a408dd 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -29,5 +29,30 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "external_ipv4": { "name": "WAN IPv4 address" }, + "external_ipv6": { "name": "WAN IPv6 address" }, + "external_ip_key": { "name": "WAN internet key address" }, + "active_connection": { + "name": "Active connection", + "state": { + "unknown": "Unknown", + "dsl": "xDSL", + "fiber": "Fiber", + "internet_key": "Internet key" + } + }, + "down_stream": { "name": "WAN download rate" }, + "up_stream": { "name": "WAN upload rate" }, + "fw_version": { "name": "Firmware version" }, + "phone_num1": { "name": "Phone number (1)" }, + "phone_num2": { "name": "Phone number (2)" }, + "sys_uptime": { "name": "Uptime" }, + "sys_cpu_usage": { "name": "CPU usage" }, + "sys_memory_usage": { "name": "Memory usage" }, + "sys_reboot_cause": { "name": "Reboot cause" } + } } } From cf6eddee74eee8763fa603d5913540bae47bc0b3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Sep 2023 09:45:56 +0200 Subject: [PATCH 635/984] Move uptimerobot coordinator to its own file (#100558) * Move uptimerobot coordinator to its own file * Fix import of coordinator in platforms --- .../components/uptimerobot/__init__.py | 72 +---------------- .../components/uptimerobot/binary_sensor.py | 2 +- .../components/uptimerobot/coordinator.py | 78 +++++++++++++++++++ .../components/uptimerobot/diagnostics.py | 2 +- .../components/uptimerobot/sensor.py | 2 +- .../components/uptimerobot/switch.py | 2 +- 6 files changed, 85 insertions(+), 73 deletions(-) create mode 100644 homeassistant/components/uptimerobot/coordinator.py diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 3cb119837d7..58979d7defb 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1,12 +1,7 @@ """The UptimeRobot integration.""" from __future__ import annotations -from pyuptimerobot import ( - UptimeRobot, - UptimeRobotAuthenticationException, - UptimeRobotException, - UptimeRobotMonitor, -) +from pyuptimerobot import UptimeRobot from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY @@ -14,9 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed 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 .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER, PLATFORMS +from .const import DOMAIN, PLATFORMS +from .coordinator import UptimeRobotDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -51,64 +46,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): - """Data update coordinator for UptimeRobot.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - config_entry_id: str, - dev_reg: dr.DeviceRegistry, - api: UptimeRobot, - ) -> None: - """Initialize coordinator.""" - super().__init__( - hass, - LOGGER, - name=DOMAIN, - update_interval=COORDINATOR_UPDATE_INTERVAL, - ) - self._config_entry_id = config_entry_id - self._device_registry = dev_reg - self.api = api - - async def _async_update_data(self) -> list[UptimeRobotMonitor]: - """Update data.""" - try: - response = await self.api.async_get_monitors() - except UptimeRobotAuthenticationException as exception: - raise ConfigEntryAuthFailed(exception) from exception - except UptimeRobotException as exception: - raise UpdateFailed(exception) from exception - - if response.status != API_ATTR_OK: - raise UpdateFailed(response.error.message) - - monitors: list[UptimeRobotMonitor] = response.data - - current_monitors = { - list(device.identifiers)[0][1] - for device in dr.async_entries_for_config_entry( - self._device_registry, self._config_entry_id - ) - } - new_monitors = {str(monitor.id) for monitor in monitors} - if stale_monitors := current_monitors - new_monitors: - for monitor_id in stale_monitors: - if device := self._device_registry.async_get_device( - identifiers={(DOMAIN, monitor_id)} - ): - self._device_registry.async_remove_device(device.id) - - # If there are new monitors, we should reload the config entry so we can - # create new devices and entities. - if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: - self.hass.async_create_task( - self.hass.config_entries.async_reload(self._config_entry_id) - ) - - return monitors diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index a4aeeb3151b..2710d5166c2 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py new file mode 100644 index 00000000000..4c1d3ea2c78 --- /dev/null +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -0,0 +1,78 @@ +"""DataUpdateCoordinator for the uptimerobot integration.""" +from __future__ import annotations + +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER + + +class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): + """Data update coordinator for UptimeRobot.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + dev_reg: dr.DeviceRegistry, + api: UptimeRobot, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=COORDINATOR_UPDATE_INTERVAL, + ) + self._config_entry_id = config_entry_id + self._device_registry = dev_reg + self.api = api + + async def _async_update_data(self) -> list[UptimeRobotMonitor]: + """Update data.""" + try: + response = await self.api.async_get_monitors() + except UptimeRobotAuthenticationException as exception: + raise ConfigEntryAuthFailed(exception) from exception + except UptimeRobotException as exception: + raise UpdateFailed(exception) from exception + + if response.status != API_ATTR_OK: + raise UpdateFailed(response.error.message) + + monitors: list[UptimeRobotMonitor] = response.data + + current_monitors = { + list(device.identifiers)[0][1] + for device in dr.async_entries_for_config_entry( + self._device_registry, self._config_entry_id + ) + } + new_monitors = {str(monitor.id) for monitor in monitors} + if stale_monitors := current_monitors - new_monitors: + for monitor_id in stale_monitors: + if device := self._device_registry.async_get_device( + identifiers={(DOMAIN, monitor_id)} + ): + self._device_registry.async_remove_device(device.id) + + # If there are new monitors, we should reload the config entry so we can + # create new devices and entities. + if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._config_entry_id) + ) + + return monitors diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py index 94710235ab7..15173a5e43c 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -8,8 +8,8 @@ from pyuptimerobot import UptimeRobotException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index f9d4097fe40..4ae40bf4134 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -13,8 +13,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 397d2085357..3406c9fe21a 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -14,8 +14,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import API_ATTR_OK, DOMAIN, LOGGER +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity From f01c71e514866b30d8c31829b6b1e9293258d00d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 19 Sep 2023 11:40:05 +0200 Subject: [PATCH 636/984] Fix lyric feedback (#100586) --- homeassistant/components/lyric/sensor.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 5bab1ffeb6f..f0a4cdfbb99 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -98,8 +98,9 @@ DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ value_fn=lambda device: get_datetime_from_future_time( device.changeableValues.nextPeriodTime ), - suitable_fn=lambda device: device.changeableValues - and device.changeableValues.nextPeriodTime, + suitable_fn=lambda device: ( + device.changeableValues and device.changeableValues.nextPeriodTime + ), ), LyricSensorEntityDescription( key="setpoint_status", @@ -109,8 +110,9 @@ DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ device.changeableValues.thermostatSetpointStatus, device.changeableValues.nextPeriodTime, ), - suitable_fn=lambda device: device.changeableValues - and device.changeableValues.thermostatSetpointStatus, + suitable_fn=lambda device: ( + device.changeableValues and device.changeableValues.thermostatSetpointStatus + ), ), ] @@ -119,7 +121,7 @@ def get_setpoint_status(status: str, time: str) -> str | None: """Get status of the setpoint.""" if status == PRESET_HOLD_UNTIL: return f"Held until {time}" - return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) + return LYRIC_SETPOINT_STATUS_NAMES.get(status) def get_datetime_from_future_time(time_str: str) -> datetime: From 11a90016d07c774fe2237e43d3d8624564fab950 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 19 Sep 2023 12:08:13 +0200 Subject: [PATCH 637/984] Change Hue zigbee connectivity sensor into an enum (#98632) --- homeassistant/components/hue/strings.json | 10 ++++++++++ homeassistant/components/hue/v2/sensor.py | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 326d08d1f7a..1224abb240e 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -94,6 +94,16 @@ } } } + }, + "sensor": { + "zigbee_connectivity": { + "state": { + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", + "connectivity_issue": "Connectivity issue", + "unidirectional_incoming": "Unidirectional incoming" + } + } } }, "options": { diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index dcdae0a3294..cc36edb88b2 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -156,6 +156,14 @@ class HueZigbeeConnectivitySensor(HueSensorBase): """Representation of a Hue ZigbeeConnectivity sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_translation_key = "zigbee_connectivity" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = [ + "connected", + "disconnected", + "connectivity_issue", + "unidirectional_incoming", + ] _attr_entity_registry_enabled_default = False @property From 2b8690d8bcd7ae4538c96c52f5153ab32fba89b4 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Sep 2023 12:44:09 +0200 Subject: [PATCH 638/984] Remove platform const in co2signal coordinator (#100592) --- homeassistant/components/co2signal/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 2538e913a68..dfb78326abe 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -9,7 +9,7 @@ from typing import Any, cast import CO2Signal from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,7 +18,6 @@ from .const import CONF_COUNTRY_CODE, DOMAIN from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError from .models import CO2SignalResponse -PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) From a2a62839bc5f9df482fea3ddd0996a970c0ad21f Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 19 Sep 2023 15:59:58 +0200 Subject: [PATCH 639/984] Add DataUpdateCoordinator to Minecraft Server (#100075) --- .coveragerc | 1 + .../components/minecraft_server/__init__.py | 221 ++---------------- .../minecraft_server/binary_sensor.py | 27 ++- .../minecraft_server/config_flow.py | 43 +++- .../components/minecraft_server/const.py | 2 - .../minecraft_server/coordinator.py | 93 ++++++++ .../components/minecraft_server/entity.py | 43 +--- .../components/minecraft_server/helpers.py | 38 +++ .../components/minecraft_server/sensor.py | 42 ++-- 9 files changed, 229 insertions(+), 281 deletions(-) create mode 100644 homeassistant/components/minecraft_server/coordinator.py create mode 100644 homeassistant/components/minecraft_server/helpers.py diff --git a/.coveragerc b/.coveragerc index 308546a5ebb..73ae1d1a466 100644 --- a/.coveragerc +++ b/.coveragerc @@ -735,6 +735,7 @@ omit = homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py homeassistant/components/minecraft_server/binary_sensor.py + homeassistant/components/minecraft_server/coordinator.py homeassistant/components/minecraft_server/entity.py homeassistant/components/minecraft_server/sensor.py homeassistant/components/minio/minio_helper.py diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index ee8bdbe2a3f..b7326735be9 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -1,31 +1,17 @@ """The Minecraft Server integration.""" from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import datetime, timedelta import logging from typing import Any -import aiodns -from mcstatus.server import JavaServer - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send import homeassistant.helpers.entity_registry as er -from homeassistant.helpers.event import async_track_time_interval -from .const import ( - DOMAIN, - KEY_LATENCY, - KEY_MOTD, - SCAN_INTERVAL, - SIGNAL_NAME_PREFIX, - SRV_RECORD_PREFIX, -) +from .const import DOMAIN, KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -34,19 +20,20 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - domain_data = hass.data.setdefault(DOMAIN, {}) - - # Create and store server instance. - config_entry_id = entry.entry_id _LOGGER.debug( - "Creating server instance for '%s' (%s)", + "Creating coordinator instance for '%s' (%s)", entry.data[CONF_NAME], entry.data[CONF_HOST], ) - server = MinecraftServer(hass, config_entry_id, entry.data) - domain_data[config_entry_id] = server - await server.async_update() - server.start_periodic_update() + + # Create coordinator instance. + config_entry_id = entry.entry_id + coordinator = MinecraftServerCoordinator(hass, config_entry_id, entry.data) + await coordinator.async_config_entry_first_refresh() + + # Store coordinator instance. + domain_data = hass.data.setdefault(DOMAIN, {}) + domain_data[config_entry_id] = coordinator # Set up platforms. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -57,7 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Minecraft Server config entry.""" config_entry_id = config_entry.entry_id - server = hass.data[DOMAIN][config_entry_id] # Unload platforms. unload_ok = await hass.config_entries.async_unload_platforms( @@ -65,7 +51,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) # Clean up. - server.stop_periodic_update() hass.data[DOMAIN].pop(config_entry_id) return unload_ok @@ -165,181 +150,3 @@ def _migrate_entity_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: ) return {"new_unique_id": new_unique_id} - - -@dataclass -class MinecraftServerData: - """Representation of Minecraft server data.""" - - latency: float | None = None - motd: str | None = None - players_max: int | None = None - players_online: int | None = None - players_list: list[str] | None = None - protocol_version: int | None = None - version: str | None = None - - -class MinecraftServer: - """Representation of a Minecraft server.""" - - def __init__( - self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] - ) -> None: - """Initialize server instance.""" - self._hass = hass - - # Server data - self.unique_id = unique_id - self.name = config_data[CONF_NAME] - self.host = config_data[CONF_HOST] - self.port = config_data[CONF_PORT] - self.online = False - self._last_status_request_failed = False - self.srv_record_checked = False - - # 3rd party library instance - self._server = JavaServer(self.host, self.port) - - # Data provided by 3rd party library - self.data: MinecraftServerData = MinecraftServerData() - - # Dispatcher signal name - self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" - - # Callback for stopping periodic update. - self._stop_periodic_update: CALLBACK_TYPE | None = None - - def start_periodic_update(self) -> None: - """Start periodic execution of update method.""" - self._stop_periodic_update = async_track_time_interval( - self._hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) - ) - - def stop_periodic_update(self) -> None: - """Stop periodic execution of update method.""" - if self._stop_periodic_update: - self._stop_periodic_update() - - async def async_check_connection(self) -> None: - """Check server connection using a 'status' request and store connection status.""" - # Check if host is a valid SRV record, if not already done. - if not self.srv_record_checked: - self.srv_record_checked = True - srv_record = await self._async_check_srv_record(self.host) - if srv_record is not None: - _LOGGER.debug( - "'%s' is a valid Minecraft SRV record ('%s:%s')", - self.host, - srv_record[CONF_HOST], - srv_record[CONF_PORT], - ) - # Overwrite host, port and 3rd party library instance - # with data extracted out of SRV record. - self.host = srv_record[CONF_HOST] - self.port = srv_record[CONF_PORT] - self._server = JavaServer(self.host, self.port) - - # Ping the server with a status request. - try: - await self._server.async_status() - self.online = True - except OSError as error: - _LOGGER.debug( - ( - "Error occurred while trying to check the connection to '%s:%s' -" - " OSError: %s" - ), - self.host, - self.port, - error, - ) - self.online = False - - async def _async_check_srv_record(self, host: str) -> dict[str, Any] | None: - """Check if the given host is a valid Minecraft SRV record.""" - srv_record = None - srv_query = None - - try: - srv_query = await aiodns.DNSResolver().query( - host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" - ) - except aiodns.error.DNSError: - # 'host' is not a SRV record. - pass - else: - # 'host' is a valid SRV record, extract the data. - srv_record = { - CONF_HOST: srv_query[0].host, - CONF_PORT: srv_query[0].port, - } - - return srv_record - - async def async_update(self, now: datetime | None = None) -> None: - """Get server data from 3rd party library and update properties.""" - # Check connection status. - server_online_old = self.online - await self.async_check_connection() - server_online = self.online - - # Inform user once about connection state changes if necessary. - if server_online_old and not server_online: - _LOGGER.warning("Connection to '%s:%s' lost", self.host, self.port) - elif not server_online_old and server_online: - _LOGGER.info("Connection to '%s:%s' (re-)established", self.host, self.port) - - # Update the server properties if server is online. - if server_online: - await self._async_status_request() - - # Notify sensors about new data. - async_dispatcher_send(self._hass, self.signal_name) - - async def _async_status_request(self) -> None: - """Request server status and update properties.""" - try: - status_response = await self._server.async_status() - - # Got answer to request, update properties. - self.data.version = status_response.version.name - self.data.protocol_version = status_response.version.protocol - self.data.players_online = status_response.players.online - self.data.players_max = status_response.players.max - self.data.latency = status_response.latency - self.data.motd = status_response.motd.to_plain() - - self.data.players_list = [] - if status_response.players.sample is not None: - for player in status_response.players.sample: - self.data.players_list.append(player.name) - self.data.players_list.sort() - - # Inform user once about successful update if necessary. - if self._last_status_request_failed: - _LOGGER.info( - "Updating the properties of '%s:%s' succeeded again", - self.host, - self.port, - ) - self._last_status_request_failed = False - except OSError as error: - # No answer to request, set all properties to unknown. - self.data.version = None - self.data.protocol_version = None - self.data.players_online = None - self.data.players_max = None - self.data.latency = None - self.data.players_list = None - self.data.motd = None - - # Inform user once about failed update if necessary. - if not self._last_status_request_failed: - _LOGGER.warning( - "Updating the properties of '%s:%s' failed - OSError: %s", - self.host, - self.port, - error, - ) - self._last_status_request_failed = True diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 51978d388b6..0446e0a2d7c 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MinecraftServer from .const import DOMAIN, ICON_STATUS, KEY_STATUS +from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity @@ -36,15 +36,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - server = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] # Add binary sensor entities. async_add_entities( [ - MinecraftServerBinarySensorEntity(server, description) + MinecraftServerBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS - ], - True, + ] ) @@ -55,15 +54,21 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, description: MinecraftServerBinarySensorEntityDescription, ) -> None: """Initialize binary sensor base entity.""" - super().__init__(server=server) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{server.unique_id}-{description.key}" + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" self._attr_is_on = False - async def async_update(self) -> None: - """Update binary sensor state.""" - self._attr_is_on = self._server.online + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return True + + @property + def is_on(self) -> bool: + """Return binary sensor state.""" + return self.coordinator.last_update_success diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index cdb345df55c..beacfde5b8e 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,15 +1,19 @@ """Config flow for Minecraft Server integration.""" from contextlib import suppress +import logging +from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from . import MinecraftServer +from . import helpers from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +_LOGGER = logging.getLogger(__name__) + class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" @@ -52,16 +56,14 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: host, CONF_PORT: port, } - server = MinecraftServer(self.hass, "dummy_unique_id", config_data) - await server.async_check_connection() - if not server.online: - # Host or port invalid or server not reachable. - errors["base"] = "cannot_connect" - else: + if await self._async_is_server_online(host, port): # Configuration data are available and no error was detected, # create configuration entry. return self.async_create_entry(title=title, data=config_data) + # Host or port invalid or server not reachable. + errors["base"] = "cannot_connect" + # Show configuration form (default form in case of no user_input, # form filled with user_input and eventually with errors otherwise). return self._show_config_form(user_input, errors) @@ -85,3 +87,30 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def _async_is_server_online(self, host: str, port: int) -> bool: + """Check server connection using a 'status' request and return result.""" + + # Check if host is a SRV record. If so, update server data. + if srv_record := await helpers.async_check_srv_record(host): + # Use extracted host and port from SRV record. + host = srv_record[CONF_HOST] + port = srv_record[CONF_PORT] + + # Send a status request to the server. + server = JavaServer(host, port) + try: + await server.async_status() + return True + except OSError as error: + _LOGGER.debug( + ( + "Error occurred while trying to check the connection to '%s:%s' -" + " OSError: %s" + ), + host, + port, + error, + ) + + return False diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 5b59913c790..ea510c467a1 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -28,8 +28,6 @@ MANUFACTURER = "Mojang AB" SCAN_INTERVAL = 60 -SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" - SRV_RECORD_PREFIX = "_minecraft._tcp" UNIT_PLAYERS_MAX = "players" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py new file mode 100644 index 00000000000..6965759e734 --- /dev/null +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -0,0 +1,93 @@ +"""The Minecraft Server integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from mcstatus.server import JavaServer + +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import helpers +from .const import SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MinecraftServerData: + """Representation of Minecraft Server data.""" + + latency: float + motd: str + players_max: int + players_online: int + players_list: list[str] + protocol_version: int + version: str + + +class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): + """Minecraft Server data update coordinator.""" + + _srv_record_checked = False + + def __init__( + self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] + ) -> None: + """Initialize coordinator instance.""" + super().__init__( + hass=hass, + name=config_data[CONF_NAME], + logger=_LOGGER, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + + # Server data + self.unique_id = unique_id + self._host = config_data[CONF_HOST] + self._port = config_data[CONF_PORT] + + # 3rd party library instance + self._server = JavaServer(self._host, self._port) + + async def _async_update_data(self) -> MinecraftServerData: + """Get server data from 3rd party library and update properties.""" + + # Check once if host is a valid Minecraft SRV record. + if not self._srv_record_checked: + self._srv_record_checked = True + if srv_record := await helpers.async_check_srv_record(self._host): + # Overwrite host, port and 3rd party library instance + # with data extracted out of the SRV record. + self._host = srv_record[CONF_HOST] + self._port = srv_record[CONF_PORT] + self._server = JavaServer(self._host, self._port) + + # Send status request to the server. + try: + status_response = await self._server.async_status() + except OSError as error: + raise UpdateFailed(error) from error + + # Got answer to request, update properties. + players_list = [] + if players := status_response.players.sample: + for player in players: + players_list.append(player.name) + players_list.sort() + + return MinecraftServerData( + version=status_response.version.name, + protocol_version=status_response.version.protocol, + players_online=status_response.players.online, + players_max=status_response.players.max, + players_list=players_list, + latency=status_response.latency, + motd=status_response.motd.to_plain(), + ) diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 4702b42beb9..e7e91c7be86 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,52 +1,27 @@ """Base entity for the Minecraft Server integration.""" - -from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import MinecraftServer from .const import DOMAIN, MANUFACTURER +from .coordinator import MinecraftServerCoordinator -class MinecraftServerEntity(Entity): +class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): """Representation of a Minecraft Server base entity.""" _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, ) -> None: """Initialize base entity.""" - self._server = server + super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, server.unique_id)}, + identifiers={(DOMAIN, coordinator.unique_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({server.data.version})", - name=server.name, - sw_version=str(server.data.protocol_version), + model=f"Minecraft Server ({coordinator.data.version})", + name=coordinator.name, + sw_version=str(coordinator.data.protocol_version), ) - self._disconnect_dispatcher: CALLBACK_TYPE | None = None - - async def async_update(self) -> None: - """Fetch data from the server.""" - raise NotImplementedError() - - async def async_added_to_hass(self) -> None: - """Connect dispatcher to signal from server.""" - self._disconnect_dispatcher = async_dispatcher_connect( - self.hass, self._server.signal_name, self._update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher before removal.""" - if self._disconnect_dispatcher: - self._disconnect_dispatcher() - - @callback - def _update_callback(self) -> None: - """Triggers update of properties after receiving signal from server.""" - self.async_schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py new file mode 100644 index 00000000000..ac9ec52f679 --- /dev/null +++ b/homeassistant/components/minecraft_server/helpers.py @@ -0,0 +1,38 @@ +"""Helper functions of Minecraft Server integration.""" +import logging +from typing import Any + +import aiodns + +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import SRV_RECORD_PREFIX + +_LOGGER = logging.getLogger(__name__) + + +async def async_check_srv_record(host: str) -> dict[str, Any] | None: + """Check if the given host is a valid Minecraft SRV record.""" + srv_record = None + + try: + srv_query = await aiodns.DNSResolver().query( + host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" + ) + except aiodns.error.DNSError: + # 'host' is not a Minecraft SRV record. + pass + else: + # 'host' is a valid Minecraft SRV record, extract the data. + srv_record = { + CONF_HOST: srv_query[0].host, + CONF_PORT: srv_query[0].port, + } + _LOGGER.debug( + "'%s' is a valid Minecraft SRV record ('%s:%s')", + host, + srv_record[CONF_HOST], + srv_record[CONF_PORT], + ) + + return srv_record diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index cb3be3e58d7..27749e5b60f 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -8,11 +8,10 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MinecraftServer, MinecraftServerData from .const import ( ATTR_PLAYERS_LIST, DOMAIN, @@ -31,6 +30,7 @@ from .const import ( UNIT_PLAYERS_MAX, UNIT_PLAYERS_ONLINE, ) +from .coordinator import MinecraftServerCoordinator, MinecraftServerData from .entity import MinecraftServerEntity @@ -118,15 +118,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - server = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] # Add sensor entities. async_add_entities( [ - MinecraftServerSensorEntity(server, description) + MinecraftServerSensorEntity(coordinator, description) for description in SENSOR_DESCRIPTIONS - ], - True, + ] ) @@ -137,24 +136,27 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, ) -> None: """Initialize sensor base entity.""" - super().__init__(server) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{server.unique_id}-{description.key}" + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._update_properties() - @property - def available(self) -> bool: - """Return sensor availability.""" - return self._server.online + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_properties() + self.async_write_ha_state() - async def async_update(self) -> None: - """Update sensor state.""" - self._attr_native_value = self.entity_description.value_fn(self._server.data) + @callback + def _update_properties(self) -> None: + """Update sensor properties.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) - if self.entity_description.attributes_fn: - self._attr_extra_state_attributes = self.entity_description.attributes_fn( - self._server.data - ) + if func := self.entity_description.attributes_fn: + self._attr_extra_state_attributes = func(self.coordinator.data) From ea78f419a998146c7a0d35bba6cbbae6fc801868 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 19 Sep 2023 10:35:23 -0400 Subject: [PATCH 640/984] Fix Roborock send command service calling not being enum (#100574) --- homeassistant/components/roborock/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 27f25208a4e..2b005ecade6 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -40,7 +40,7 @@ class RoborockEntity(Entity): async def send( self, - command: RoborockCommand, + command: RoborockCommand | str, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Send a command to a vacuum cleaner.""" @@ -48,7 +48,7 @@ class RoborockEntity(Entity): response = await self._api.send_command(command, params) except RoborockException as err: raise HomeAssistantError( - f"Error while calling {command.name} with {params}" + f"Error while calling {command.name if isinstance(command, RoborockCommand) else command} with {params}" ) from err return response From d9227a7e3d74a09a8cef2b94d632d368b71b8a6f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 19 Sep 2023 16:43:00 +0200 Subject: [PATCH 641/984] Add Spotify code owner (#100597) --- CODEOWNERS | 4 ++-- homeassistant/components/spotify/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e985b6f20b4..b3d2889b108 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1191,8 +1191,8 @@ build.json @home-assistant/supervisor /homeassistant/components/spider/ @peternijssen /tests/components/spider/ @peternijssen /homeassistant/components/splunk/ @Bre77 -/homeassistant/components/spotify/ @frenck -/tests/components/spotify/ @frenck +/homeassistant/components/spotify/ @frenck @joostlek +/tests/components/spotify/ @frenck @joostlek /homeassistant/components/sql/ @gjohansson-ST @dougiteixeira /tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 7ca1533744c..84f2bc102e3 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -1,7 +1,7 @@ { "domain": "spotify", "name": "Spotify", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/spotify", From c3f74ae022a6a398650f5d934a8a9522da46445a Mon Sep 17 00:00:00 2001 From: Ian Date: Tue, 19 Sep 2023 08:10:29 -0700 Subject: [PATCH 642/984] Add config-flow to NextBus (#92149) --- homeassistant/components/nextbus/__init__.py | 19 +- .../components/nextbus/config_flow.py | 236 ++++++++++++++++++ .../components/nextbus/manifest.json | 1 + homeassistant/components/nextbus/sensor.py | 100 ++++---- homeassistant/components/nextbus/strings.json | 33 +++ homeassistant/components/nextbus/util.py | 2 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- tests/components/nextbus/conftest.py | 36 +++ tests/components/nextbus/test_config_flow.py | 162 ++++++++++++ tests/components/nextbus/test_sensor.py | 219 ++++++++++------ tests/components/nextbus/test_util.py | 34 +++ 12 files changed, 710 insertions(+), 137 deletions(-) create mode 100644 homeassistant/components/nextbus/config_flow.py create mode 100644 homeassistant/components/nextbus/strings.json create mode 100644 tests/components/nextbus/conftest.py create mode 100644 tests/components/nextbus/test_config_flow.py create mode 100644 tests/components/nextbus/test_util.py diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index 4891af77b28..b582f82b929 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -1 +1,18 @@ -"""NextBus sensor.""" +"""NextBus platform.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up platforms for NextBus.""" + 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/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py new file mode 100644 index 00000000000..d7149bcc9f4 --- /dev/null +++ b/homeassistant/components/nextbus/config_flow.py @@ -0,0 +1,236 @@ +"""Config flow to configure the Nextbus integration.""" +from collections import Counter +import logging + +from py_nextbus import NextBusClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _dict_to_select_selector(options: dict[str, str]) -> SelectSelector: + return SelectSelector( + SelectSelectorConfig( + options=sorted( + ( + SelectOptionDict(value=key, label=value) + for key, value in options.items() + ), + key=lambda o: o["label"], + ), + mode=SelectSelectorMode.DROPDOWN, + ) + ) + + +def _get_agency_tags(client: NextBusClient) -> dict[str, str]: + return {a["tag"]: a["title"] for a in client.get_agency_list()["agency"]} + + +def _get_route_tags(client: NextBusClient, agency_tag: str) -> dict[str, str]: + return {a["tag"]: a["title"] for a in client.get_route_list(agency_tag)["route"]} + + +def _get_stop_tags( + client: NextBusClient, agency_tag: str, route_tag: str +) -> dict[str, str]: + route_config = client.get_route_config(route_tag, agency_tag) + tags = {a["tag"]: a["title"] for a in route_config["route"]["stop"]} + title_counts = Counter(tags.values()) + + stop_directions: dict[str, str] = {} + for direction in route_config["route"]["direction"]: + for stop in direction["stop"]: + stop_directions[stop["tag"]] = direction["name"] + + # Append directions for stops with shared titles + for tag, title in tags.items(): + if title_counts[title] > 1: + tags[tag] = f"{title} ({stop_directions[tag]})" + + return tags + + +def _validate_import( + client: NextBusClient, agency_tag: str, route_tag: str, stop_tag: str +) -> str | tuple[str, str, str]: + agency_tags = _get_agency_tags(client) + agency = agency_tags.get(agency_tag) + if not agency: + return "invalid_agency" + + route_tags = _get_route_tags(client, agency_tag) + route = route_tags.get(route_tag) + if not route: + return "invalid_route" + + stop_tags = _get_stop_tags(client, agency_tag, route_tag) + stop = stop_tags.get(stop_tag) + if not stop: + return "invalid_stop" + + return agency, route, stop + + +def _unique_id_from_data(data: dict[str, str]) -> str: + return f"{data[CONF_AGENCY]}_{data[CONF_ROUTE]}_{data[CONF_STOP]}" + + +class NextBusFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle Nextbus configuration.""" + + VERSION = 1 + + _agency_tags: dict[str, str] + _route_tags: dict[str, str] + _stop_tags: dict[str, str] + + def __init__(self): + """Initialize NextBus config flow.""" + self.data: dict[str, str] = {} + self._client = NextBusClient(output_format="json") + _LOGGER.info("Init new config flow") + + async def async_step_import(self, config_input: dict[str, str]) -> FlowResult: + """Handle import of config.""" + agency_tag = config_input[CONF_AGENCY] + route_tag = config_input[CONF_ROUTE] + stop_tag = config_input[CONF_STOP] + + validation_result = await self.hass.async_add_executor_job( + _validate_import, + self._client, + agency_tag, + route_tag, + stop_tag, + ) + if isinstance(validation_result, str): + return self.async_abort(reason=validation_result) + + data = { + CONF_AGENCY: agency_tag, + CONF_ROUTE: route_tag, + CONF_STOP: stop_tag, + CONF_NAME: config_input.get( + CONF_NAME, + f"{config_input[CONF_AGENCY]} {config_input[CONF_ROUTE]}", + ), + } + + await self.async_set_unique_id(_unique_id_from_data(data)) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=" ".join(validation_result), + data=data, + ) + + async def async_step_user( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Handle a flow initiated by the user.""" + return await self.async_step_agency(user_input) + + async def async_step_agency( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select agency.""" + if user_input is not None: + self.data[CONF_AGENCY] = user_input[CONF_AGENCY] + + return await self.async_step_route() + + self._agency_tags = await self.hass.async_add_executor_job( + _get_agency_tags, self._client + ) + + return self.async_show_form( + step_id="agency", + data_schema=vol.Schema( + { + vol.Required(CONF_AGENCY): _dict_to_select_selector( + self._agency_tags + ), + } + ), + ) + + async def async_step_route( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select route.""" + if user_input is not None: + self.data[CONF_ROUTE] = user_input[CONF_ROUTE] + + return await self.async_step_stop() + + self._route_tags = await self.hass.async_add_executor_job( + _get_route_tags, self._client, self.data[CONF_AGENCY] + ) + + return self.async_show_form( + step_id="route", + data_schema=vol.Schema( + { + vol.Required(CONF_ROUTE): _dict_to_select_selector( + self._route_tags + ), + } + ), + ) + + async def async_step_stop( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select stop.""" + + if user_input is not None: + self.data[CONF_STOP] = user_input[CONF_STOP] + + await self.async_set_unique_id(_unique_id_from_data(self.data)) + self._abort_if_unique_id_configured() + + agency_tag = self.data[CONF_AGENCY] + route_tag = self.data[CONF_ROUTE] + stop_tag = self.data[CONF_STOP] + + agency_name = self._agency_tags[agency_tag] + route_name = self._route_tags[route_tag] + stop_name = self._stop_tags[stop_tag] + + return self.async_create_entry( + title=f"{agency_name} {route_name} {stop_name}", + data=self.data, + ) + + self._stop_tags = await self.hass.async_add_executor_job( + _get_stop_tags, + self._client, + self.data[CONF_AGENCY], + self.data[CONF_ROUTE], + ) + + return self.async_show_form( + step_id="stop", + data_schema=vol.Schema( + { + vol.Required(CONF_STOP): _dict_to_select_selector(self._stop_tags), + } + ), + ) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 4b8bd1a9294..15eb9b4e245 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -2,6 +2,7 @@ "domain": "nextbus", "name": "NextBus", "codeowners": ["@vividboarder"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index b8f36e10fa1..1582ec25ffe 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -12,14 +12,16 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +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.util.dt import utc_from_timestamp -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN from .util import listify, maybe_first _LOGGER = logging.getLogger(__name__) @@ -34,59 +36,54 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def validate_value(value_name, value, value_list): - """Validate tag value is in the list of items and logs error if not.""" - valid_values = {v["tag"]: v["title"] for v in value_list} - if value not in valid_values: - _LOGGER.error( - "Invalid %s tag `%s`. Please use one of the following: %s", - value_name, - value, - ", ".join(f"{title}: {tag}" for tag, title in valid_values.items()), - ) - return False - - return True - - -def validate_tags(client, agency, route, stop): - """Validate provided tags.""" - # Validate agencies - if not validate_value("agency", agency, client.get_agency_list()["agency"]): - return False - - # Validate the route - if not validate_value("route", route, client.get_route_list(agency)["route"]): - return False - - # Validate the stop - route_config = client.get_route_config(route, agency)["route"] - if not validate_value("stop", stop, route_config["stop"]): - return False - - return True - - -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: - """Load values from configuration and initialize the platform.""" - agency = config[CONF_AGENCY] - route = config[CONF_ROUTE] - stop = config[CONF_STOP] - name = config.get(CONF_NAME) + """Initialize nextbus import from config.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2024.4.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NextBus", + }, + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Load values from configuration and initialize the platform.""" client = NextBusClient(output_format="json") - # Ensures that the tags provided are valid, also logs out valid values - if not validate_tags(client, agency, route, stop): - _LOGGER.error("Invalid config value(s)") - return + _LOGGER.debug(config.data) - add_entities([NextBusDepartureSensor(client, agency, route, stop, name)], True) + sensor = NextBusDepartureSensor( + client, + config.unique_id, + config.data[CONF_AGENCY], + config.data[CONF_ROUTE], + config.data[CONF_STOP], + config.data.get(CONF_NAME) or config.title, + ) + + async_add_entities((sensor,), True) class NextBusDepartureSensor(SensorEntity): @@ -103,17 +100,14 @@ class NextBusDepartureSensor(SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_icon = "mdi:bus" - def __init__(self, client, agency, route, stop, name=None): + def __init__(self, client, unique_id, agency, route, stop, name): """Initialize sensor with all required config.""" self.agency = agency self.route = route self.stop = stop self._attr_extra_state_attributes = {} - - # Maybe pull a more user friendly name from the API here - self._attr_name = f"{agency} {route}" - if name: - self._attr_name = name + self._attr_unique_id = unique_id + self._attr_name = name self._client = client diff --git a/homeassistant/components/nextbus/strings.json b/homeassistant/components/nextbus/strings.json new file mode 100644 index 00000000000..4f54ebf1656 --- /dev/null +++ b/homeassistant/components/nextbus/strings.json @@ -0,0 +1,33 @@ +{ + "title": "NextBus predictions", + "config": { + "step": { + "agency": { + "title": "Select metro agency", + "data": { + "agency": "Metro agency" + } + }, + "route": { + "title": "Select route", + "data": { + "route": "Route" + } + }, + "stop": { + "title": "Select stop", + "data": { + "stop": "Stop" + } + } + }, + "error": { + "invalid_agency": "The agency value selected is not valid", + "invalid_route": "The route value selected is not valid", + "invalid_stop": "The stop value selected is not valid" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/nextbus/util.py b/homeassistant/components/nextbus/util.py index c753c452546..73b3b400ff4 100644 --- a/homeassistant/components/nextbus/util.py +++ b/homeassistant/components/nextbus/util.py @@ -17,7 +17,7 @@ def listify(maybe_list: Any) -> list[Any]: return [maybe_list] -def maybe_first(maybe_list: list[Any]) -> Any: +def maybe_first(maybe_list: list[Any] | None) -> Any: """Return the first item out of a list or returns back the input.""" if isinstance(maybe_list, list) and maybe_list: return maybe_list[0] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 229682eff1d..0d20e80317c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -301,6 +301,7 @@ FLOWS = { "netatmo", "netgear", "nexia", + "nextbus", "nextcloud", "nextdns", "nfandroidtv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9fcb5389415..d1efd527b69 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3712,9 +3712,8 @@ "supported_by": "overkiz" }, "nextbus": { - "name": "NextBus", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextcloud": { @@ -6798,6 +6797,7 @@ "mobile_app", "moehlenhoff_alpha2", "moon", + "nextbus", "nmap_tracker", "plant", "proximity", diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py new file mode 100644 index 00000000000..a38f3fd850e --- /dev/null +++ b/tests/components/nextbus/conftest.py @@ -0,0 +1,36 @@ +"""Test helpers for NextBus tests.""" +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_nextbus_lists(mock_nextbus: MagicMock) -> MagicMock: + """Mock all list functions in nextbus to test validate logic.""" + instance = mock_nextbus.return_value + instance.get_agency_list.return_value = { + "agency": [{"tag": "sf-muni", "title": "San Francisco Muni"}] + } + instance.get_route_list.return_value = { + "route": [{"tag": "F", "title": "F - Market & Wharves"}] + } + instance.get_route_config.return_value = { + "route": { + "stop": [ + {"tag": "5650", "title": "Market St & 7th St"}, + {"tag": "5651", "title": "Market St & 7th St"}, + ], + "direction": [ + { + "name": "Outbound", + "stop": [{"tag": "5650"}], + }, + { + "name": "Inbound", + "stop": [{"tag": "5651"}], + }, + ], + } + } + + return instance diff --git a/tests/components/nextbus/test_config_flow.py b/tests/components/nextbus/test_config_flow.py new file mode 100644 index 00000000000..9f427757183 --- /dev/null +++ b/tests/components/nextbus/test_config_flow.py @@ -0,0 +1,162 @@ +"""Test the NextBus config flow.""" +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.nextbus.const import ( + CONF_AGENCY, + CONF_ROUTE, + CONF_STOP, + DOMAIN, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +def mock_setup_entry() -> Generator[MagicMock, None, None]: + """Create a mock for the nextbus component setup.""" + with patch( + "homeassistant.components.nextbus.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nextbus() -> Generator[MagicMock, None, None]: + """Create a mock py_nextbus module.""" + with patch("homeassistant.components.nextbus.config_flow.NextBusClient") as client: + yield client + + +async def test_import_config( + hass: HomeAssistant, mock_setup_entry: MagicMock, mock_nextbus_lists: MagicMock +) -> None: + """Test config is imported and component set up.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + data = { + CONF_AGENCY: "sf-muni", + CONF_ROUTE: "F", + CONF_STOP: "5650", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert ( + result.get("title") + == "San Francisco Muni F - Market & Wharves Market St & 7th St (Outbound)" + ) + assert result.get("data") == {CONF_NAME: "sf-muni F", **data} + + assert len(mock_setup_entry.mock_calls) == 1 + + # Check duplicate entries are aborted + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("override", "expected_reason"), + ( + ({CONF_AGENCY: "not muni"}, "invalid_agency"), + ({CONF_ROUTE: "not F"}, "invalid_route"), + ({CONF_STOP: "not 5650"}, "invalid_stop"), + ), +) +async def test_import_config_invalid( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_nextbus_lists: MagicMock, + override: dict[str, str], + expected_reason: str, +) -> None: + """Test user is redirected to user setup flow because they have invalid config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + data = { + CONF_AGENCY: "sf-muni", + CONF_ROUTE: "F", + CONF_STOP: "5650", + **override, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == expected_reason + + +async def test_user_config( + hass: HomeAssistant, mock_setup_entry: MagicMock, mock_nextbus_lists: MagicMock +) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "agency" + + # Select agency + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AGENCY: "sf-muni", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == "form" + assert result.get("step_id") == "route" + + # Select route + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ROUTE: "F", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "stop" + + # Select stop + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STOP: "5650", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == { + "agency": "sf-muni", + "route": "F", + "stop": "5650", + } + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index 4884d04d3aa..071dd95fe7b 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -1,15 +1,24 @@ """The tests for the nexbus sensor component.""" +from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -import homeassistant.components.nextbus.sensor as nextbus -import homeassistant.components.sensor as sensor -from homeassistant.core import HomeAssistant +from homeassistant.components import sensor +from homeassistant.components.nextbus.const import ( + CONF_AGENCY, + CONF_ROUTE, + CONF_STOP, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component +from tests.common import MockConfigEntry VALID_AGENCY = "sf-muni" VALID_ROUTE = "F" @@ -17,24 +26,34 @@ VALID_STOP = "5650" VALID_AGENCY_TITLE = "San Francisco Muni" VALID_ROUTE_TITLE = "F-Market & Wharves" VALID_STOP_TITLE = "Market St & 7th St" -SENSOR_ID_SHORT = "sensor.sf_muni_f" +SENSOR_ID = "sensor.san_francisco_muni_f_market_wharves_market_st_7th_st" -CONFIG_BASIC = { - "sensor": { - "platform": "nextbus", - "agency": VALID_AGENCY, - "route": VALID_ROUTE, - "stop": VALID_STOP, - } +PLATFORM_CONFIG = { + sensor.DOMAIN: { + "platform": DOMAIN, + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: VALID_ROUTE, + CONF_STOP: VALID_STOP, + }, } -CONFIG_INVALID_MISSING = {"sensor": {"platform": "nextbus"}} + +CONFIG_BASIC = { + DOMAIN: { + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: VALID_ROUTE, + CONF_STOP: VALID_STOP, + } +} BASIC_RESULTS = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "direction": { "title": "Outbound", "prediction": [ @@ -48,24 +67,19 @@ BASIC_RESULTS = { } -async def assert_setup_sensor(hass, config, count=1): - """Set up the sensor and assert it's been created.""" - with assert_setup_component(count): - assert await async_setup_component(hass, sensor.DOMAIN, config) - await hass.async_block_till_done() - - @pytest.fixture -def mock_nextbus(): +def mock_nextbus() -> Generator[MagicMock, None, None]: """Create a mock py_nextbus module.""" with patch( - "homeassistant.components.nextbus.sensor.NextBusClient" - ) as NextBusClient: - yield NextBusClient + "homeassistant.components.nextbus.sensor.NextBusClient", + ) as client: + yield client @pytest.fixture -def mock_nextbus_predictions(mock_nextbus): +def mock_nextbus_predictions( + mock_nextbus: MagicMock, +) -> Generator[MagicMock, None, None]: """Create a mock of NextBusClient predictions.""" instance = mock_nextbus.return_value instance.get_predictions_for_multi_stops.return_value = BASIC_RESULTS @@ -73,63 +87,69 @@ def mock_nextbus_predictions(mock_nextbus): return instance.get_predictions_for_multi_stops -@pytest.fixture -def mock_nextbus_lists(mock_nextbus): - """Mock all list functions in nextbus to test validate logic.""" - instance = mock_nextbus.return_value - instance.get_agency_list.return_value = { - "agency": [{"tag": "sf-muni", "title": "San Francisco Muni"}] - } - instance.get_route_list.return_value = { - "route": [{"tag": "F", "title": "F - Market & Wharves"}] - } - instance.get_route_config.return_value = { - "route": {"stop": [{"tag": "5650", "title": "Market St & 7th St"}]} - } +async def assert_setup_sensor( + hass: HomeAssistant, + config: dict[str, str], + expected_state=ConfigEntryState.LOADED, +) -> MockConfigEntry: + """Set up the sensor and assert it's been created.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config[DOMAIN], + title=f"{VALID_AGENCY_TITLE} {VALID_ROUTE_TITLE} {VALID_STOP_TITLE}", + unique_id=f"{VALID_AGENCY}_{VALID_ROUTE}_{VALID_STOP}", + ) + 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.state is expected_state + + return config_entry + + +async def test_legacy_yaml_setup( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test config setup and yaml deprecation.""" + with patch( + "homeassistant.components.nextbus.config_flow.NextBusClient", + ) as NextBusClient: + NextBusClient.return_value.get_predictions_for_multi_stops.return_value = ( + BASIC_RESULTS + ) + await async_setup_component(hass, sensor.DOMAIN, PLATFORM_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue async def test_valid_config( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists + hass: HomeAssistant, mock_nextbus: MagicMock, mock_nextbus_lists: MagicMock ) -> None: """Test that sensor is set up properly with valid config.""" await assert_setup_sensor(hass, CONFIG_BASIC) -async def test_invalid_config( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists -) -> None: - """Checks that component is not setup when missing information.""" - await assert_setup_sensor(hass, CONFIG_INVALID_MISSING, count=0) - - -async def test_validate_tags( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists -) -> None: - """Test that additional validation against the API is successful.""" - # with self.subTest('Valid everything'): - assert nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, VALID_ROUTE, VALID_STOP) - # with self.subTest('Invalid agency'): - assert not nextbus.validate_tags( - mock_nextbus(), "not-valid", VALID_ROUTE, VALID_STOP - ) - - # with self.subTest('Invalid route'): - assert not nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, "0", VALID_STOP) - - # with self.subTest('Invalid stop'): - assert not nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, VALID_ROUTE, 0) - - async def test_verify_valid_state( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify all attributes are set from a valid response.""" await assert_setup_sensor(hass, CONFIG_BASIC) + mock_nextbus_predictions.assert_called_once_with( [{"stop_tag": VALID_STOP, "route_tag": VALID_ROUTE}], VALID_AGENCY ) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "2019-03-28T21:09:31+00:00" assert state.attributes["agency"] == VALID_AGENCY_TITLE @@ -140,14 +160,20 @@ async def test_verify_valid_state( async def test_message_dict( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a single dict message is rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": {"text": "Message"}, "direction": { "title": "Outbound", @@ -162,20 +188,26 @@ async def test_message_dict( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.attributes["message"] == "Message" async def test_message_list( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a list of messages are rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": [{"text": "Message 1"}, {"text": "Message 2"}], "direction": { "title": "Outbound", @@ -190,20 +222,26 @@ async def test_message_list( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.attributes["message"] == "Message 1 -- Message 2" async def test_direction_list( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a list of messages are rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": [{"text": "Message 1"}, {"text": "Message 2"}], "direction": [ { @@ -224,7 +262,7 @@ async def test_direction_list( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "2019-03-28T21:09:31+00:00" assert state.attributes["agency"] == VALID_AGENCY_TITLE @@ -235,46 +273,67 @@ async def test_direction_list( async def test_custom_name( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a custom name can be set via config.""" config = deepcopy(CONFIG_BASIC) - config["sensor"]["name"] = "Custom Name" + config[DOMAIN][CONF_NAME] = "Custom Name" await assert_setup_sensor(hass, config) state = hass.states.get("sensor.custom_name") assert state is not None + assert state.name == "Custom Name" +@pytest.mark.parametrize( + "prediction_results", + ( + {}, + {"Error": "Failed"}, + ), +) async def test_no_predictions( - hass: HomeAssistant, mock_nextbus, mock_nextbus_predictions, mock_nextbus_lists + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_predictions: MagicMock, + mock_nextbus_lists: MagicMock, + prediction_results: dict[str, str], ) -> None: """Verify there are no exceptions when no predictions are returned.""" - mock_nextbus_predictions.return_value = {} + mock_nextbus_predictions.return_value = prediction_results await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "unknown" async def test_verify_no_upcoming( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify attributes are set despite no upcoming times.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "direction": {"title": "Outbound", "prediction": []}, } } await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "unknown" assert state.attributes["upcoming"] == "No upcoming predictions" diff --git a/tests/components/nextbus/test_util.py b/tests/components/nextbus/test_util.py new file mode 100644 index 00000000000..798171464e6 --- /dev/null +++ b/tests/components/nextbus/test_util.py @@ -0,0 +1,34 @@ +"""Test NextBus util functions.""" +from typing import Any + +import pytest + +from homeassistant.components.nextbus.util import listify, maybe_first + + +@pytest.mark.parametrize( + ("input", "expected"), + ( + ("foo", ["foo"]), + (["foo"], ["foo"]), + (None, []), + ), +) +def test_listify(input: Any, expected: list[Any]) -> None: + """Test input listification.""" + assert listify(input) == expected + + +@pytest.mark.parametrize( + ("input", "expected"), + ( + ([], []), + (None, None), + ("test", "test"), + (["test"], "test"), + (["test", "second"], "test"), + ), +) +def test_maybe_first(input: list[Any] | None, expected: Any) -> None: + """Test maybe getting the first thing from a list.""" + assert maybe_first(input) == expected From 7c4f08e6b3b66463dea126cd887dfb9a98297cf5 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 19 Sep 2023 17:15:43 +0200 Subject: [PATCH 643/984] Fix xiaomi_miio button platform regression (#100527) --- homeassistant/components/xiaomi_miio/button.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 9ed9b780911..e5e11b85e58 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -169,8 +169,12 @@ class XiaomiGenericCoordinatedButton(XiaomiCoordinatedMiioEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" method = getattr(self._device, self.entity_description.method_press) - await self._try_command( - self.entity_description.method_press_error_message, - method, - self.entity_description.method_press_params, - ) + params = self.entity_description.method_press_params + if params is not None: + await self._try_command( + self.entity_description.method_press_error_message, method, params + ) + else: + await self._try_command( + self.entity_description.method_press_error_message, method + ) From 2ad0fd1ce11a33ffc0333dadb158c0cd7f422607 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 19 Sep 2023 11:30:38 -0400 Subject: [PATCH 644/984] Adjust hassfest.manifest based on config.action (#100577) --- script/hassfest/manifest.py | 14 +++++++++----- script/hassfest/model.py | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 9323b8e86c0..acdea23444d 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -366,15 +366,19 @@ def _sort_manifest_keys(key: str) -> str: return _SORT_KEYS.get(key, key) -def sort_manifest(integration: Integration) -> bool: +def sort_manifest(integration: Integration, config: Config) -> bool: """Sort manifest.""" keys = list(integration.manifest.keys()) if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys: manifest = {key: integration.manifest[key] for key in keys_sorted} - integration.manifest_path.write_text(json.dumps(manifest, indent=2)) + if config.action == "generate": + integration.manifest_path.write_text(json.dumps(manifest, indent=2)) + text = "have been sorted" + else: + text = "are not sorted correctly" integration.add_error( "manifest", - "Manifest keys have been sorted: domain, name, then alphabetical order", + f"Manifest keys {text}: domain, name, then alphabetical order", ) return True return False @@ -387,9 +391,9 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: for integration in integrations.values(): validate_manifest(integration, core_components_dir) if not integration.errors: - if sort_manifest(integration): + if sort_manifest(integration, config): manifests_resorted.append(integration.manifest_path) - if manifests_resorted: + if config.action == "generate" and manifests_resorted: subprocess.run( ["pre-commit", "run", "--hook-stage", "manual", "prettier", "--files"] + manifests_resorted, diff --git a/script/hassfest/model.py b/script/hassfest/model.py index e4f93c80e81..7df65b8221e 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass, field import json import pathlib -from typing import Any +from typing import Any, Literal @dataclass @@ -26,7 +26,7 @@ class Config: specific_integrations: list[pathlib.Path] | None root: pathlib.Path - action: str + action: Literal["validate", "generate"] requirements: bool errors: list[Error] = field(default_factory=list) cache: dict[str, Any] = field(default_factory=dict) From 8dd3d6f989bf5c7604337376bf45bf7546aa6a11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 19 Sep 2023 17:40:55 +0200 Subject: [PATCH 645/984] Call async added to hass super in Livisi (#100446) --- homeassistant/components/livisi/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 388788d3dea..b7b9bdc8521 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -67,6 +67,7 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callback for reachability.""" + await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, From 0eca433004835cd68a36858d40d591fc6d381511 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Sep 2023 18:58:46 +0200 Subject: [PATCH 646/984] Update zeroconf discovery to use IPAddress objects to avoid conversions (#100567) --- homeassistant/components/zeroconf/__init__.py | 55 +++++++++++++---- .../androidtv_remote/test_config_flow.py | 25 ++++---- tests/components/apple_tv/test_config_flow.py | 58 +++++++++--------- tests/components/awair/const.py | 6 +- tests/components/axis/test_config_flow.py | 21 +++---- tests/components/axis/test_device.py | 5 +- tests/components/baf/test_config_flow.py | 17 +++--- tests/components/blebox/test_config_flow.py | 17 +++--- tests/components/bond/test_config_flow.py | 59 ++++++++++--------- .../components/bosch_shc/test_config_flow.py | 9 +-- tests/components/brother/test_config_flow.py | 21 +++---- tests/components/daikin/test_config_flow.py | 5 +- tests/components/devolo_home_control/const.py | 14 +++-- tests/components/devolo_home_network/const.py | 14 +++-- tests/components/doorbird/test_config_flow.py | 25 ++++---- tests/components/elgato/test_config_flow.py | 21 +++---- .../enphase_envoy/test_config_flow.py | 21 +++---- tests/components/esphome/test_config_flow.py | 41 ++++++------- .../forked_daapd/test_config_flow.py | 25 ++++---- tests/components/freebox/test_config_flow.py | 5 +- .../components/gogogate2/test_config_flow.py | 21 +++---- tests/components/guardian/test_config_flow.py | 9 +-- .../homekit_controller/test_config_flow.py | 5 +- .../components/homewizard/test_config_flow.py | 25 ++++---- tests/components/hue/test_config_flow.py | 29 ++++----- .../test_config_flow.py | 10 ++-- tests/components/ipp/__init__.py | 10 ++-- tests/components/ipp/test_config_flow.py | 5 +- tests/components/kodi/util.py | 11 ++-- tests/components/lifx/test_config_flow.py | 13 ++-- tests/components/lookin/__init__.py | 5 +- tests/components/lookin/test_config_flow.py | 3 +- tests/components/loqed/test_config_flow.py | 5 +- .../lutron_caseta/test_config_flow.py | 17 +++--- .../modern_forms/test_config_flow.py | 17 +++--- tests/components/nam/test_config_flow.py | 5 +- tests/components/nanoleaf/test_config_flow.py | 9 +-- tests/components/netatmo/test_config_flow.py | 5 +- tests/components/nut/test_config_flow.py | 5 +- .../components/octoprint/test_config_flow.py | 9 +-- tests/components/overkiz/test_config_flow.py | 5 +- tests/components/plugwise/test_config_flow.py | 17 +++--- .../pure_energie/test_config_flow.py | 9 +-- tests/components/rachio/test_config_flow.py | 13 ++-- .../rainmachine/test_config_flow.py | 21 +++---- tests/components/roku/__init__.py | 6 +- tests/components/roomba/test_config_flow.py | 9 +-- .../components/samsungtv/test_config_flow.py | 13 ++-- tests/components/shelly/test_config_flow.py | 13 ++-- tests/components/smappee/test_config_flow.py | 33 ++++++----- tests/components/sonos/conftest.py | 5 +- tests/components/sonos/test_config_flow.py | 5 +- .../components/soundtouch/test_config_flow.py | 5 +- tests/components/spotify/test_config_flow.py | 5 +- .../synology_dsm/test_config_flow.py | 9 +-- .../system_bridge/test_config_flow.py | 9 +-- tests/components/tado/test_config_flow.py | 9 +-- tests/components/thread/test_config_flow.py | 5 +- tests/components/tradfri/test_config_flow.py | 25 ++++---- tests/components/vizio/const.py | 6 +- tests/components/vizio/test_config_flow.py | 3 +- tests/components/volumio/test_config_flow.py | 5 +- tests/components/wled/test_config_flow.py | 25 ++++---- .../xiaomi_aqara/test_config_flow.py | 13 ++-- .../xiaomi_miio/test_config_flow.py | 21 +++---- tests/components/yeelight/__init__.py | 5 +- tests/components/yeelight/test_config_flow.py | 17 +++--- tests/components/zeroconf/test_init.py | 3 + tests/components/zha/test_config_flow.py | 29 ++++----- tests/components/zwave_js/test_config_flow.py | 7 ++- tests/components/zwave_me/test_config_flow.py | 5 +- 71 files changed, 575 insertions(+), 462 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 085e720e3df..bf0984d3989 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -98,16 +98,43 @@ CONFIG_SCHEMA = vol.Schema( @dataclass(slots=True) class ZeroconfServiceInfo(BaseServiceInfo): - """Prepared info from mDNS entries.""" + """Prepared info from mDNS entries. - host: str - addresses: list[str] + The ip_address is the most recently updated address + that is not a link local or unspecified address. + + The ip_addresses are all addresses in order of most + recently updated to least recently updated. + + The host is the string representation of the ip_address. + + The addresses are the string representations of the + ip_addresses. + + It is recommended to use the ip_address to determine + the address to connect to as it will be the most + recently updated address that is not a link local + or unspecified address. + """ + + ip_address: IPv4Address | IPv6Address + ip_addresses: list[IPv4Address | IPv6Address] port: int | None hostname: str type: str name: str properties: dict[str, Any] + @property + def host(self) -> str: + """Return the host.""" + return _stringify_ip_address(self.ip_address) + + @property + def addresses(self) -> list[str]: + """Return the addresses.""" + return [_stringify_ip_address(ip_address) for ip_address in self.ip_addresses] + @bind_hass async def async_get_instance(hass: HomeAssistant) -> HaZeroconf: @@ -536,10 +563,8 @@ def async_get_homekit_discovery( return None -@lru_cache(maxsize=256) # matches to the cache in zeroconf itself -def _stringify_ip_address(ip_addr: IPv4Address | IPv6Address) -> str: - """Stringify an IP address.""" - return str(ip_addr) +# matches to the cache in zeroconf itself +_stringify_ip_address = lru_cache(maxsize=256)(str) def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: @@ -547,14 +572,18 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: # See https://ietf.org/rfc/rfc6763.html#section-6.4 and # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings # for property keys and values - if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): + if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - host: str | None = None + if TYPE_CHECKING: + ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) + else: + ip_addresses = maybe_ip_addresses + ip_address: IPv4Address | IPv6Address | None = None for ip_addr in ip_addresses: if not ip_addr.is_link_local and not ip_addr.is_unspecified: - host = _stringify_ip_address(ip_addr) + ip_address = ip_addr break - if not host: + if not ip_address: return None # Service properties are always bytes if they are set from the network. @@ -571,8 +600,8 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: assert service.server is not None, "server cannot be none if there are addresses" return ZeroconfServiceInfo( - host=host, - addresses=[_stringify_ip_address(ip_addr) for ip_addr in ip_addresses], + ip_address=ip_address, + ip_addresses=ip_addresses, port=service.port, hostname=service.server, type=service.type, diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index a2792efb0f3..fb4bc829160 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Android TV Remote config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth @@ -431,8 +432,8 @@ async def test_zeroconf_flow_success( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -509,8 +510,8 @@ async def test_zeroconf_flow_cannot_connect( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -560,8 +561,8 @@ async def test_zeroconf_flow_pairing_invalid_auth( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -643,8 +644,8 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -696,8 +697,8 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -729,8 +730,8 @@ async def test_zeroconf_flow_abort_if_mac_is_missing( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 6256d1dde9c..513c21f7ce5 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -1,5 +1,5 @@ """Test config flow.""" -from ipaddress import IPv4Address +from ipaddress import IPv4Address, ip_address from unittest.mock import ANY, patch from pyatv import exceptions @@ -21,8 +21,8 @@ from .common import airplay_service, create_conf, mrp_service, raop_service from tests.common import MockConfigEntry DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_touch-able._tcp.local.", @@ -32,8 +32,8 @@ DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_raop._tcp.local.", @@ -558,8 +558,8 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -579,8 +579,8 @@ async def test_zeroconf_add_mrp_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.2", - addresses=["127.0.0.2"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", port=None, name="Kitchen", @@ -594,8 +594,8 @@ async def test_zeroconf_add_mrp_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, name="Kitchen", @@ -836,8 +836,8 @@ async def test_zeroconf_abort_if_other_in_progress( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -859,8 +859,8 @@ async def test_zeroconf_abort_if_other_in_progress( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -885,8 +885,8 @@ async def test_zeroconf_missing_device_during_protocol_resolve( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -907,8 +907,8 @@ async def test_zeroconf_missing_device_during_protocol_resolve( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -943,8 +943,8 @@ async def test_zeroconf_additional_protocol_resolve_failure( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -965,8 +965,8 @@ async def test_zeroconf_additional_protocol_resolve_failure( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -1003,8 +1003,8 @@ async def test_zeroconf_pair_additionally_found_protocols( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -1046,8 +1046,8 @@ async def test_zeroconf_pair_additionally_found_protocols( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -1158,8 +1158,8 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", port=None, type="_touch-able._tcp.local.", diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py index cead20d10af..f24eaeb971d 100644 --- a/tests/components/awair/const.py +++ b/tests/components/awair/const.py @@ -1,5 +1,7 @@ """Constants used in Awair tests.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST @@ -9,8 +11,8 @@ LOCAL_CONFIG = {CONF_HOST: "192.0.2.5"} CLOUD_UNIQUE_ID = "foo@bar.com" LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26" ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="192.0.2.5", - addresses=["192.0.2.5"], + ip_address=ip_address("192.0.2.5"), + ip_addresses=[ip_address("192.0.2.5")], hostname="mock_hostname", name="awair12345", port=None, diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index d535b4bcb1f..06fad5329ea 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -1,4 +1,5 @@ """Test Axis config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -294,8 +295,8 @@ async def test_reauth_flow_update_configuration( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=DEFAULT_HOST, - addresses=[DEFAULT_HOST], + ip_address=ip_address(DEFAULT_HOST), + ip_addresses=[ip_address(DEFAULT_HOST)], port=80, hostname=f"axis-{MAC.lower()}.local.", type="_axis-video._tcp.local.", @@ -377,8 +378,8 @@ async def test_discovery_flow( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=DEFAULT_HOST, - addresses=[DEFAULT_HOST], + ip_address=ip_address(DEFAULT_HOST), + ip_addresses=[ip_address(DEFAULT_HOST)], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=80, @@ -431,8 +432,8 @@ async def test_discovered_device_already_configured( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="2.3.4.5", - addresses=["2.3.4.5"], + ip_address=ip_address("2.3.4.5"), + ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=8080, @@ -505,8 +506,8 @@ async def test_discovery_flow_updated_configuration( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="", - addresses=[""], + ip_address=None, + ip_addresses=[], hostname="mock_hostname", name="", port=0, @@ -554,8 +555,8 @@ async def test_discovery_flow_ignore_non_axis_device( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="169.254.3.4", - addresses=["169.254.3.4"], + ip_address=ip_address("169.254.3.4"), + ip_addresses=[ip_address("169.254.3.4")], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=80, diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index ef2cc7f448a..ff7ff343a06 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -1,4 +1,5 @@ """Test Axis device.""" +from ipaddress import ip_address from unittest import mock from unittest.mock import Mock, patch @@ -117,8 +118,8 @@ async def test_update_address( await hass.config_entries.flow.async_init( AXIS_DOMAIN, data=zeroconf.ZeroconfServiceInfo( - host="2.3.4.5", - addresses=["2.3.4.5"], + ip_address=ip_address("2.3.4.5"), + ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", name="name", port=80, diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py index 871e75f7c23..f770db05096 100644 --- a/tests/components/baf/test_config_flow.py +++ b/tests/components/baf/test_config_flow.py @@ -1,5 +1,6 @@ """Test the baf config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -87,8 +88,8 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, @@ -125,8 +126,8 @@ async def test_zeroconf_updates_existing_ip(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, @@ -145,8 +146,8 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="testfan", port=None, @@ -164,8 +165,8 @@ async def test_user_flow_is_not_blocked_by_discovery(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 0f2cfebd12e..765f7af3f62 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -1,4 +1,5 @@ """Test Home Assistant config flow for BleBox devices.""" +from ipaddress import ip_address from unittest.mock import DEFAULT, AsyncMock, PropertyMock, patch import blebox_uniapi @@ -211,8 +212,8 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -251,8 +252,8 @@ async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) - config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -275,8 +276,8 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) - config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -301,8 +302,8 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index fab579a81a3..91d628e4841 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from http import HTTPStatus +from ipaddress import ip_address from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -203,8 +204,8 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -227,7 +228,7 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -241,8 +242,8 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -264,7 +265,7 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -278,8 +279,8 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -301,7 +302,7 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -319,8 +320,8 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -342,7 +343,7 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "discovered-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "discovered-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -360,8 +361,8 @@ async def test_zeroconf_form_with_token_available_name_unavailable( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -383,7 +384,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( assert result2["type"] == "create_entry" assert result2["title"] == "ZXXX12345" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "discovered-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -404,8 +405,8 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -417,7 +418,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert len(mock_setup_entry.mock_calls) == 1 @@ -442,8 +443,8 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -455,7 +456,7 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert len(mock_setup_entry.mock_calls) == 1 assert entry.state is ConfigEntryState.LOADED @@ -488,8 +489,8 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -501,7 +502,7 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert entry.data[CONF_ACCESS_TOKEN] == "discovered-token" # entry2 should not get changed assert entry2.data[CONF_ACCESS_TOKEN] == "correct-token" @@ -515,7 +516,7 @@ async def test_zeroconf_already_configured_no_reload_same_host( entry = MockConfigEntry( domain=DOMAIN, unique_id="already-registered-bond-id", - data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "correct-token"}, + data={CONF_HOST: "127.0.0.3", CONF_ACCESS_TOKEN: "correct-token"}, ) entry.add_to_hass(hass) @@ -526,8 +527,8 @@ async def test_zeroconf_already_configured_no_reload_same_host( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="stored-host", - addresses=["stored-host"], + ip_address=ip_address("127.0.0.3"), + ip_addresses=[ip_address("127.0.0.3")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -548,8 +549,8 @@ async def test_zeroconf_form_unexpected_error(hass: HomeAssistant) -> None: hass, source=config_entries.SOURCE_ZEROCONF, initial_input=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 92f49b86ef7..e5d0abb3c9d 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Bosch SHC config flow.""" +from ipaddress import ip_address from unittest.mock import PropertyMock, mock_open, patch from boschshcpy.exceptions import ( @@ -22,8 +23,8 @@ MOCK_SETTINGS = { "device": {"mac": "test-mac", "hostname": "test-host"}, } DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="shc012345.local.", name="Bosch SHC [test-mac]._http._tcp.local.", port=0, @@ -548,8 +549,8 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant, mock_zeroconf: None) result = await hass.config_entries.flow.async_init( DOMAIN, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="notboschshc", port=None, diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 629295e09e0..f83f882b8a0 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the Brother Printer config flow.""" +from ipaddress import ip_address import json from unittest.mock import patch @@ -155,8 +156,8 @@ async def test_zeroconf_snmp_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -178,8 +179,8 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -210,8 +211,8 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -238,8 +239,8 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -264,8 +265,8 @@ async def test_zeroconf_confirm_create_entry(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 27c3b7d9ea3..4d54d7483df 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Daikin config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import PropertyMock, patch from aiohttp import ClientError, web_exceptions @@ -119,8 +120,8 @@ async def test_api_password_abort(hass: HomeAssistant) -> None: ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=HOST, - addresses=[HOST], + ip_address=ip_address(HOST), + ip_addresses=[ip_address(HOST)], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 96090195d20..3351e42c988 100644 --- a/tests/components/devolo_home_control/const.py +++ b/tests/components/devolo_home_control/const.py @@ -1,10 +1,12 @@ """Constants used for mocking data.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="192.168.0.1", - addresses=["192.168.0.1"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -21,8 +23,8 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( ) DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -31,8 +33,8 @@ DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( ) DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index bc2ef2d87b2..8cf63cf07ae 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -1,5 +1,7 @@ """Constants used for mocking data.""" +from ipaddress import ip_address + from devolo_plc_api.device_api import ( UPDATE_AVAILABLE, WIFI_BAND_2G, @@ -30,8 +32,8 @@ CONNECTED_STATIONS = [ NO_CONNECTED_STATIONS = [] DISCOVERY_INFO = ZeroconfServiceInfo( - host=IP, - addresses=[IP], + ip_address=ip_address(IP), + ip_addresses=[ip_address(IP)], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -51,8 +53,8 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) DISCOVERY_INFO_CHANGED = ZeroconfServiceInfo( - host=IP_ALT, - addresses=[IP_ALT], + ip_address=ip_address(IP_ALT), + ip_addresses=[ip_address(IP_ALT)], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -72,8 +74,8 @@ DISCOVERY_INFO_CHANGED = ZeroconfServiceInfo( ) DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index e982f4ca172..7ad7fbe07ac 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -1,4 +1,5 @@ """Test the DoorBird config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, Mock, patch import pytest @@ -84,8 +85,8 @@ async def test_form_zeroconf_wrong_oui(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.8", - addresses=["192.168.1.8"], + ip_address=ip_address("192.168.1.8"), + ip_addresses=[ip_address("192.168.1.8")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -104,8 +105,8 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="169.254.103.61", - addresses=["169.254.103.61"], + ip_address=ip_address("169.254.103.61"), + ip_addresses=[ip_address("169.254.103.61")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -131,8 +132,8 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="4.4.4.4", - addresses=["4.4.4.4"], + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -152,8 +153,8 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -179,8 +180,8 @@ async def test_form_zeroconf_correct_oui(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -244,8 +245,8 @@ async def test_form_zeroconf_correct_oui_wrong_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 1b71a29632f..bfae6fc9a17 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Elgato Key Light config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from elgato import ElgatoConnectionError @@ -52,8 +53,8 @@ async def test_full_zeroconf_flow_implementation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="mock_name", port=9123, @@ -110,8 +111,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=9123, @@ -150,8 +151,8 @@ async def test_zeroconf_device_exists_abort( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=9123, @@ -171,8 +172,8 @@ async def test_zeroconf_device_exists_abort( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.2", - addresses=["127.0.0.2"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="mock_name", port=9123, @@ -200,8 +201,8 @@ async def test_zeroconf_during_onboarding( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="mock_name", port=9123, diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index a4481f4ed51..25517e390ca 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Enphase Envoy config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock from pyenphase import EnvoyAuthenticationError, EnvoyError @@ -175,8 +176,8 @@ async def test_zeroconf_pre_token_firmware( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -216,8 +217,8 @@ async def test_zeroconf_token_firmware( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -278,8 +279,8 @@ async def test_zeroconf_serial_already_exists( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="4.4.4.4", - addresses=["4.4.4.4"], + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -301,8 +302,8 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="mock_name", port=None, @@ -325,8 +326,8 @@ async def test_zeroconf_host_already_exists( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 63e18107623..01ba07852d6 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1,5 +1,6 @@ """Test config flow.""" import asyncio +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, patch @@ -121,8 +122,8 @@ async def test_user_sets_unique_id( ) -> None: """Test that the user flow sets the unique id.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -198,8 +199,8 @@ async def test_user_causes_zeroconf_to_abort( ) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -558,8 +559,8 @@ async def test_discovery_initiation( ) -> None: """Test discovery importing works.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", name="mock_name", port=6053, @@ -590,8 +591,8 @@ async def test_discovery_no_mac( ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -618,8 +619,8 @@ async def test_discovery_already_configured( entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -639,8 +640,8 @@ async def test_discovery_duplicate_data( ) -> None: """Test discovery aborts if same mDNS packet arrives.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", name="mock_name", port=6053, @@ -674,8 +675,8 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1173,8 +1174,8 @@ async def test_zeroconf_encryption_key_via_dashboard( ) -> None: """Test encryption key retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1239,8 +1240,8 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1305,8 +1306,8 @@ async def test_zeroconf_no_encryption_key_via_dashboard( ) -> None: """Test encryption key not retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index fc02cdb4123..080e47acc3e 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -1,4 +1,5 @@ """The config flow tests for the forked_daapd media player platform.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -103,8 +104,8 @@ async def test_zeroconf_updates_title(hass: HomeAssistant, config_entry) -> None config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 discovery_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.1", - addresses=["192.168.1.1"], + ip_address=ip_address("192.168.1.1"), + ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -138,8 +139,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: """Test that an invalid zeroconf entry doesn't work.""" # test with no discovery properties discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -153,8 +154,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with forked-daapd version < 27 discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -168,8 +169,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with verbose mtd-version from Firefly discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -183,8 +184,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with svn mtd-version from Firefly discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -201,8 +202,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: async def test_config_flow_zeroconf_valid(hass: HomeAssistant) -> None: """Test that a valid zeroconf entry works.""" discovery_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.1", - addresses=["192.168.1.1"], + ip_address=ip_address("192.168.1.1"), + ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", name="mock_name", port=23, diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index d8ea7107f23..9d6f95b2559 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Freebox config flow.""" +from ipaddress import ip_address from unittest.mock import Mock, patch from freebox_api.exceptions import ( @@ -19,8 +20,8 @@ from .const import MOCK_HOST, MOCK_PORT from tests.common import MockConfigEntry MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="192.168.0.254", - addresses=["192.168.0.254"], + ip_address=ip_address("192.168.0.254"), + ip_addresses=[ip_address("192.168.0.254")], port=80, hostname="Freebox-Server.local.", type="_fbx-api._tcp.local.", diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 32d0f197bb5..6de04125783 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the GogoGate2 component.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from ismartgate import GogoGate2Api, ISmartGateApi @@ -104,8 +105,8 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -132,8 +133,8 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -157,8 +158,8 @@ async def test_form_homekit_ip_address_already_setup(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -176,8 +177,8 @@ async def test_form_homekit_ip_address(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -259,8 +260,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index cb28ea22a37..3d0be516dea 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the Elexa Guardian config flow.""" +from ipaddress import ip_address from unittest.mock import patch from aioguardian.errors import GuardianError @@ -79,8 +80,8 @@ async def test_step_user(hass: HomeAssistant, config, setup_guardian) -> None: async def test_step_zeroconf(hass: HomeAssistant, setup_guardian) -> None: """Test the zeroconf step.""" zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], port=7777, hostname="GVC1-ABCD.local.", type="_api._udp.local.", @@ -109,8 +110,8 @@ async def test_step_zeroconf(hass: HomeAssistant, setup_guardian) -> None: async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], port=7777, hostname="GVC1-ABCD.local.", type="_api._udp.local.", diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index c989bc01ff2..469bd8618d2 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for homekit_controller config flow.""" import asyncio +from ipaddress import ip_address import unittest.mock from unittest.mock import AsyncMock, patch @@ -174,10 +175,10 @@ def get_device_discovery_info( ) -> zeroconf.ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" result = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname=device.description.name, name=device.description.name + "._hap._tcp.local.", - addresses=["127.0.0.1"], port=8080, properties={ "md": device.description.model, diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7a1652549d7..7c6fb0bdb0d 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,4 +1,5 @@ """Test the homewizard config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError @@ -58,8 +59,8 @@ async def test_discovery_flow_works( """Test discovery setup flow works.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -131,8 +132,8 @@ async def test_discovery_flow_during_onboarding( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="mock_type", @@ -177,8 +178,8 @@ async def test_discovery_flow_during_onboarding_disabled_api( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="mock_type", @@ -229,8 +230,8 @@ async def test_discovery_disabled_api( """Test discovery detecting disabled api.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -279,8 +280,8 @@ async def test_discovery_missing_data_in_service_info( """Test discovery detecting missing discovery info.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -310,8 +311,8 @@ async def test_discovery_invalid_api( """Test discovery detecting invalid_api.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 6fa03e1de13..29b94b17da1 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for Philips Hue config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import Mock, patch from aiohue.discovery import URL_NUPNP @@ -416,8 +417,8 @@ async def test_bridge_homekit( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, @@ -466,8 +467,8 @@ async def test_bridge_homekit_already_configured( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, @@ -568,8 +569,8 @@ async def test_bridge_zeroconf( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.217", - addresses=["192.168.1.217"], + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -604,8 +605,8 @@ async def test_bridge_zeroconf_already_exists( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.217", - addresses=["192.168.1.217"], + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -629,8 +630,8 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::eeb5:faff:fe84:b17d", - addresses=["fd00::eeb5:faff:fe84:b17d"], + ip_address=ip_address("fd00::eeb5:faff:fe84:b17d"), + ip_addresses=[ip_address("fd00::eeb5:faff:fe84:b17d")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -677,8 +678,8 @@ async def test_bridge_connection_failed( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="blah", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -698,8 +699,8 @@ async def test_bridge_connection_failed( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 943de66baac..f39b4c1f68e 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Logitech Harmony Hub config flow.""" import asyncio +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, patch @@ -12,9 +13,10 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +ZEROCONF_HOST = "1.2.3.4" HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name="Hunter Douglas Powerview Hub._hap._tcp.local.", port=None, @@ -23,8 +25,8 @@ HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( ) ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name="Hunter Douglas Powerview Hub._powerview._tcp.local.", port=None, diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index f66630b2a69..ca374bd7e5e 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -1,5 +1,7 @@ """Tests for the IPP integration.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.ipp.const import CONF_BASE_PATH from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL @@ -31,8 +33,8 @@ MOCK_USER_INPUT = { MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( type=IPP_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname=ZEROCONF_HOSTNAME, port=ZEROCONF_PORT, properties={"rp": ZEROCONF_RP}, @@ -41,8 +43,8 @@ MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( type=IPPS_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname=ZEROCONF_HOSTNAME, port=ZEROCONF_PORT, properties={"rp": ZEROCONF_RP}, diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 0daf8a0f7e0..5dd6c1af5bf 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the IPP config flow.""" import dataclasses +from ipaddress import ip_address import json from unittest.mock import MagicMock, patch @@ -326,7 +327,9 @@ async def test_zeroconf_with_uuid_device_exists_abort_new_host( """Test we abort zeroconf flow if printer already configured.""" mock_config_entry.add_to_hass(hass) - discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO, host="1.2.3.9") + discovery_info = dataclasses.replace( + MOCK_ZEROCONF_IPP_SERVICE_INFO, ip_address=ip_address("1.2.3.9") + ) discovery_info.properties = { **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, "UUID": "cfe92100-67c4-11d4-a45f-f8d027761251", diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index 9fb215e2d8a..2b9d819c244 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -1,4 +1,6 @@ """Test the Kodi config flow.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.kodi.const import DEFAULT_SSL @@ -8,7 +10,6 @@ TEST_HOST = { "ssl": DEFAULT_SSL, } - TEST_CREDENTIALS = {"username": "username", "password": "password"} @@ -16,8 +17,8 @@ TEST_WS_PORT = {"ws_port": 9090} UUID = "11111111-1111-1111-1111-111111111111" TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=8080, hostname="hostname.local.", type="_xbmc-jsonrpc-h._tcp.local.", @@ -27,8 +28,8 @@ TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( TEST_DISCOVERY_WO_UUID = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=8080, hostname="hostname.local.", type="_xbmc-jsonrpc-h._tcp.local.", diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index 2adea42bed4..1b7da4f864a 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the lifx integration config flow.""" +from ipaddress import ip_address import socket from unittest.mock import patch @@ -388,8 +389,8 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, @@ -443,8 +444,8 @@ async def test_discovered_by_dhcp_or_discovery( ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, @@ -484,8 +485,8 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py index 11426f20e57..bfbb5f66887 100644 --- a/tests/components/lookin/__init__.py +++ b/tests/components/lookin/__init__.py @@ -1,6 +1,7 @@ """Tests for the lookin integration.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import MagicMock, patch from aiolookin import Climate, Device, Remote @@ -18,8 +19,8 @@ DEFAULT_ENTRY_TITLE = DEVICE_NAME ZC_NAME = f"LOOKin_{DEVICE_ID}" ZC_TYPE = "_lookin._tcp." ZEROCONF_DATA = ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=f"{ZC_NAME.lower()}.local.", port=80, type=ZC_TYPE, diff --git a/tests/components/lookin/test_config_flow.py b/tests/components/lookin/test_config_flow.py index 1fd4479d100..873e21a5cac 100644 --- a/tests/components/lookin/test_config_flow.py +++ b/tests/components/lookin/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses +from ipaddress import ip_address from unittest.mock import patch from aiolookin import NoUsableService @@ -135,7 +136,7 @@ async def test_discovered_zeroconf(hass: HomeAssistant) -> None: entry = hass.config_entries.async_entries(DOMAIN)[0] zc_data_new_ip = dataclasses.replace(ZEROCONF_DATA) - zc_data_new_ip.host = "127.0.0.2" + zc_data_new_ip.ip_address = ip_address("127.0.0.2") with _patch_get_info(), patch( f"{MODULE}.async_setup_entry", return_value=True diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index c9c577e7199..617b6818a64 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Loqed config flow.""" +from ipaddress import ip_address import json from unittest.mock import Mock, patch @@ -16,8 +17,8 @@ from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.12.34", - addresses=["127.0.0.1"], + ip_address=ip_address("192.168.12.34"), + ip_addresses=[ip_address("192.168.12.34")], hostname="LOQED-ffeeddccbbaa.local", name="mock_name", port=9123, diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 7f6a1b60511..da26a55a4ef 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Lutron Caseta config flow.""" import asyncio +from ipaddress import ip_address from pathlib import Path import ssl from unittest.mock import AsyncMock, patch @@ -404,8 +405,8 @@ async def test_zeroconf_host_already_configured( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, @@ -432,8 +433,8 @@ async def test_zeroconf_lutron_id_already_configured(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, @@ -455,8 +456,8 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="notlutron-abc.local.", name="mock_name", port=None, @@ -483,8 +484,8 @@ async def test_zeroconf(hass: HomeAssistant, source, tmp_path: Path) -> None: DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 540a8fef93d..49bac6a5bb0 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Modern Forms config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch import aiohttp @@ -65,8 +66,8 @@ async def test_full_zeroconf_flow_implementation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -134,8 +135,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -166,8 +167,8 @@ async def test_zeroconf_confirm_connection_error( CONF_NAME: "test", }, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.com.", name="mock_name", port=None, @@ -236,8 +237,8 @@ async def test_zeroconf_with_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 78a96e148ce..a8f1245d9d6 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -1,5 +1,6 @@ """Define tests for the Nettigo Air Monitor config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError @@ -14,8 +15,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="10.10.2.3", - addresses=["10.10.2.3"], + ip_address=ip_address("10.10.2.3"), + ip_addresses=[ip_address("10.10.2.3")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 9a7f4a2bc50..2fce4e55bbc 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Nanoleaf config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from aionanoleaf import InvalidToken, Unauthorized, Unavailable @@ -237,8 +238,8 @@ async def test_discovery_link_unavailable( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery_info}", port=None, @@ -372,8 +373,8 @@ async def test_import_discovery_integration( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery}", port=None, diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index a89fff13cdd..56d319b1631 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Netatmo config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pyatmo.const import ALL_SCOPES @@ -44,8 +45,8 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: "netatmo", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 8ce4916fc66..46bc2bc2a64 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Network UPS Tools (NUT) config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pynut2.nut2 import PyNUTError @@ -36,8 +37,8 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=1234, diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index f2423f6da27..e3cf45708fa 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -1,4 +1,5 @@ """Test the OctoPrint config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pyoctoprintapi import ApiError, DiscoverySettings @@ -174,8 +175,8 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=80, @@ -496,8 +497,8 @@ async def test_duplicate_zerconf_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=80, diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 89b0b7e8427..a9d950a3a66 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for Overkiz (by Somfy) config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError @@ -37,8 +38,8 @@ MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID2)] FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( - host="192.168.0.51", - addresses=["192.168.0.51"], + ip_address=ip_address("192.168.0.51"), + ip_addresses=[ip_address("192.168.0.51")], port=443, hostname=f"gateway-{TEST_GATEWAY_ID}.local.", type="_kizbox._tcp.local.", diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 6ca1e14a4ca..438ab1b0870 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Plugwise config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from plugwise.exceptions import ( @@ -36,8 +37,8 @@ TEST_USERNAME = "smile" TEST_USERNAME2 = "stretch" TEST_DISCOVERY = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], # The added `-2` is to simulate mDNS collision hostname=f"{TEST_HOSTNAME}-2.local.", name="mock_name", @@ -51,8 +52,8 @@ TEST_DISCOVERY = ZeroconfServiceInfo( ) TEST_DISCOVERY2 = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME2}.local.", name="mock_name", port=DEFAULT_PORT, @@ -65,8 +66,8 @@ TEST_DISCOVERY2 = ZeroconfServiceInfo( ) TEST_DISCOVERY_ANNA = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME}.local.", name="mock_name", port=DEFAULT_PORT, @@ -79,8 +80,8 @@ TEST_DISCOVERY_ANNA = ZeroconfServiceInfo( ) TEST_DISCOVERY_ADAM = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME2}.local.", name="mock_name", port=DEFAULT_PORT, diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py index 2b00e975a8e..992ce8bbb2c 100644 --- a/tests/components/pure_energie/test_config_flow.py +++ b/tests/components/pure_energie/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Pure Energie config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock from gridnet import GridNetConnectionError @@ -47,8 +48,8 @@ async def test_full_zeroconf_flow_implementationn( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -103,8 +104,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 8d66725d20e..26083f51e63 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Rachio config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries @@ -114,8 +115,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -139,8 +140,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -165,8 +166,8 @@ async def test_form_homekit_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 0d95cbcce31..5fa457bf771 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the OpenUV config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -157,8 +158,8 @@ async def test_step_homekit_zeroconf_ip_already_exists( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -185,8 +186,8 @@ async def test_step_homekit_zeroconf_ip_change( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.2", - addresses=["192.168.1.2"], + ip_address=ip_address("192.168.1.2"), + ip_addresses=[ip_address("192.168.1.2")], hostname="mock_hostname", name="mock_name", port=None, @@ -214,8 +215,8 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -264,8 +265,8 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -284,8 +285,8 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index 2ae0b308f9a..fc12bb9731d 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -1,4 +1,6 @@ """Tests for the Roku component.""" +from ipaddress import ip_address + from homeassistant.components import ssdp, zeroconf from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL @@ -23,8 +25,8 @@ MOCK_SSDP_DISCOVERY_INFO = ssdp.SsdpServiceInfo( HOMEKIT_HOST = "192.168.1.161" MOCK_HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host=HOMEKIT_HOST, - addresses=[HOMEKIT_HOST], + ip_address=ip_address(HOMEKIT_HOST), + ip_addresses=[ip_address(HOMEKIT_HOST)], hostname="mock_hostname", name="onn._hap._tcp.local.", port=None, diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 0b39c34d3b8..f62ca1a73b9 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -1,4 +1,5 @@ """Test the iRobot Roomba config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -36,25 +37,25 @@ DISCOVERY_DEVICES = [ ( config_entries.SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=MOCK_IP, + ip_address=ip_address(MOCK_IP), + ip_addresses=[ip_address(MOCK_IP)], hostname="irobot-blid.local.", name="irobot-blid._amzn-alexa._tcp.local.", type="_amzn-alexa._tcp.local.", port=443, properties={}, - addresses=[MOCK_IP], ), ), ( config_entries.SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=MOCK_IP, + ip_address=ip_address(MOCK_IP), + ip_addresses=[ip_address(MOCK_IP)], hostname="roomba-blid.local.", name="roomba-blid._amzn-alexa._tcp.local.", type="_amzn-alexa._tcp.local.", port=443, properties={}, - addresses=[MOCK_IP], ), ), ] diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 3c4b982b000..a70a0042fcd 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 ipaddress import ip_address from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest @@ -130,8 +131,8 @@ MOCK_DHCP_DATA = dhcp.DhcpServiceInfo( ) EXISTING_IP = "192.168.40.221" MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="fake_host", - addresses=["fake_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=1234, @@ -975,7 +976,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_HOST] == "127.0.0.1" assert result["data"][CONF_NAME] == "Living Room" assert result["data"][CONF_MAC] == "aa:bb:ww:ii:ff:ii" assert result["data"][CONF_MANUFACTURER] == "Samsung" @@ -1273,7 +1274,9 @@ async def test_update_missing_mac_unique_id_added_from_zeroconf( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac and unique id added.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) + entry = MockConfigEntry( + domain=DOMAIN, data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, unique_id=None + ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -1539,7 +1542,7 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( """Test missing mac and unique id added.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_OLD_ENTRY, + data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 7a29d7b1a42..073847e0308 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import replace +from ipaddress import ip_address from unittest.mock import AsyncMock, patch from aioshelly.exceptions import ( @@ -29,8 +30,8 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-12345", port=None, @@ -38,8 +39,8 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( type="mock_type", ) DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-AABBCCDDEEFF", port=None, @@ -651,7 +652,9 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: ): result = await hass.config_entries.flow.async_init( DOMAIN, - data=replace(DISCOVERY_INFO, host=config_flow.INTERNAL_WIFI_AP_IP), + data=replace( + DISCOVERY_INFO, ip_address=ip_address(config_flow.INTERNAL_WIFI_AP_IP) + ), context={"source": config_entries.SOURCE_ZEROCONF}, ) assert result["type"] == data_entry_flow.FlowResultType.ABORT diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index a6e8f8ae45c..f6f5ab66708 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Smappee component config flow module.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import patch from homeassistant import data_entry_flow, setup @@ -59,8 +60,8 @@ async def test_show_zeroconf_connection_error_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -91,8 +92,8 @@ async def test_show_zeroconf_connection_error_form_next_generation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee5001000212.local.", type="_ssh._tcp.local.", @@ -174,8 +175,8 @@ async def test_zeroconf_wrong_mdns(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="example.local.", type="_ssh._tcp.local.", @@ -285,8 +286,8 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -335,8 +336,8 @@ async def test_zeroconf_abort_if_cloud_device_exists(hass: HomeAssistant) -> Non DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -357,8 +358,8 @@ async def test_zeroconf_confirm_abort_if_cloud_device_exists( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -480,8 +481,8 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -559,8 +560,8 @@ async def test_full_zeroconf_flow_next_generation(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee5001000212.local.", type="_ssh._tcp.local.", diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index bab2b89009f..cb912af1cf6 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -1,5 +1,6 @@ """Configuration for Sonos tests.""" from copy import copy +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -69,8 +70,8 @@ class SonosMockEvent: def zeroconf_payload(): """Return a default zeroconf payload.""" return zeroconf.ZeroconfServiceInfo( - host="192.168.4.2", - addresses=["192.168.4.2"], + ip_address=ip_address("192.168.4.2"), + ip_addresses=[ip_address("192.168.4.2")], hostname="Sonos-aaa", name="Sonos-aaa@Living Room._sonos._tcp.local.", port=None, diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 270bdec4b52..2fd8ad110df 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -1,6 +1,7 @@ """Test the sonos config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries @@ -162,8 +163,8 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.107", - addresses=["192.168.1.107"], + ip_address=ip_address("192.168.1.107"), + ip_addresses=[ip_address("192.168.1.107")], port=1443, hostname="sonos5CAAFDE47AC8.local.", type="_sonos._tcp.local.", diff --git a/tests/components/soundtouch/test_config_flow.py b/tests/components/soundtouch/test_config_flow.py index 68f884ca006..896202355ac 100644 --- a/tests/components/soundtouch/test_config_flow.py +++ b/tests/components/soundtouch/test_config_flow.py @@ -1,4 +1,5 @@ """Test config flow.""" +from ipaddress import ip_address from unittest.mock import patch from requests import RequestException @@ -75,8 +76,8 @@ async def test_zeroconf_flow_create_entry( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - host=DEVICE_1_IP, - addresses=[DEVICE_1_IP], + ip_address=ip_address(DEVICE_1_IP), + ip_addresses=[ip_address(DEVICE_1_IP)], port=8090, hostname="Bose-SM2-060000000001.local.", type="_soundtouch._tcp.local.", diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index 46d9741684a..7940964d68f 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Spotify config flow.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -22,8 +23,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator BLANK_ZEROCONF_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index ef4dee7c597..4d4ba583169 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Synology DSM config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -666,8 +667,8 @@ async def test_discovered_via_zeroconf(hass: HomeAssistant, service: MagicMock) DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], port=5000, hostname="mydsm.local.", type="_http._tcp.local.", @@ -714,8 +715,8 @@ async def test_discovered_via_zeroconf_missing_mac( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], port=5000, hostname="mydsm.local.", type="_http._tcp.local.", diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index d01ed9a3ff8..56afc87c3bb 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -1,5 +1,6 @@ """Test the System Bridge config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from systembridgeconnector.const import MODEL_SYSTEM, TYPE_DATA_UPDATE @@ -37,8 +38,8 @@ FIXTURE_ZEROCONF_INPUT = { } FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( - host="test-bridge", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=9170, hostname="test-bridge.local.", type="_system-bridge._tcp.local.", @@ -55,8 +56,8 @@ FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( ) FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=9170, hostname="test-bridge.local.", type="_system-bridge._tcp.local.", diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index dcbb33b587e..c4a39914e53 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Tado config flow.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import MagicMock, patch import pytest @@ -222,8 +223,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -249,8 +250,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 7ff096795ca..51ebe3b5976 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Thread config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant.components import thread, zeroconf @@ -6,10 +7,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType TEST_ZEROCONF_RECORD = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="HomeAssistant OpenThreadBorderRouter #0BBF", name="HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", - addresses=["127.0.0.1"], port=8080, properties={ "rv": "1", diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 9eff7335820..3f5c71645c8 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Tradfri config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, patch import pytest @@ -113,8 +114,8 @@ async def test_discovery_connection( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -148,8 +149,8 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="new-host", - addresses=["new-host"], + ip_address=ip_address("123.123.123.124"), + ip_addresses=[ip_address("123.123.123.124")], hostname="mock_hostname", name="mock_name", port=None, @@ -161,7 +162,7 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: assert flow["type"] == data_entry_flow.FlowResultType.ABORT assert flow["reason"] == "already_configured" - assert entry.data["host"] == "new-host" + assert entry.data["host"] == "123.123.123.124" async def test_duplicate_discovery( @@ -172,8 +173,8 @@ async def test_duplicate_discovery( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -188,8 +189,8 @@ async def test_duplicate_discovery( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -205,7 +206,7 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: """Test a duplicate discovery host aborts and updates existing entry.""" entry = MockConfigEntry( domain="tradfri", - data={"host": "some-host"}, + data={"host": "123.123.123.123"}, ) entry.add_to_hass(hass) @@ -213,8 +214,8 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="some-host", - addresses=["some-host"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 119443962fc..849c13d4396 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -1,4 +1,6 @@ """Constants for the Vizio integration tests.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, @@ -197,8 +199,8 @@ ZEROCONF_HOST = HOST.split(":")[0] ZEROCONF_PORT = HOST.split(":")[1] MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name=ZEROCONF_NAME, port=ZEROCONF_PORT, diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 4c47a0c5640..578d79fcba0 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -801,8 +801,9 @@ async def test_zeroconf_flow_with_port_in_host( entry.add_to_hass(hass) # Try rediscovering same device, this time with port already in host + # This test needs to be refactored as the port is never in the host + # field of the zeroconf service info discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) - discovery_info.host = f"{discovery_info.host}:{discovery_info.port}" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index 5d734d1b2d5..841b558eba3 100644 --- a/tests/components/volumio/test_config_flow.py +++ b/tests/components/volumio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Volumio config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -19,8 +20,8 @@ TEST_CONNECTION = { TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=3000, diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 9f99bd58615..de01510adb3 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the WLED config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock import pytest @@ -44,8 +45,8 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -88,8 +89,8 @@ async def test_zeroconf_during_onboarding( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -133,8 +134,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -193,8 +194,8 @@ async def test_zeroconf_without_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -218,8 +219,8 @@ async def test_zeroconf_with_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -243,8 +244,8 @@ async def test_zeroconf_with_cct_channel_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 2f049a86620..d15a442a840 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Xiaomi Aqara config flow.""" +from ipaddress import ip_address from socket import gaierror from unittest.mock import Mock, patch @@ -403,8 +404,8 @@ async def test_zeroconf_success(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -450,8 +451,8 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -470,8 +471,8 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name="not-a-xiaomi-aqara-gateway", port=None, diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 848bb7c8d9f..a436908b44f 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Xiaomi Miio config flow.""" +from ipaddress import ip_address from unittest.mock import Mock, patch from construct.core import ChecksumError @@ -426,8 +427,8 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -469,8 +470,8 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name="not-a-xiaomi-miio-device", port=None, @@ -489,8 +490,8 @@ async def test_zeroconf_no_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=None, - addresses=[], + ip_address=None, + ip_addresses=[], hostname="mock_hostname", name=None, port=None, @@ -509,8 +510,8 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -791,8 +792,8 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=zeroconf_name_to_test, port=None, diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index d60ead707fb..c7d279220f8 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -1,5 +1,6 @@ """Tests for the Yeelight integration.""" from datetime import timedelta +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from async_upnp_client.search import SsdpSearchListener @@ -42,8 +43,8 @@ CAPABILITIES = { ID_DECIMAL = f"{int(ID, 16):08d}" ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], port=54321, hostname=f"yeelink-light-strip1_miio{ID_DECIMAL}.local.", type="_miio._udp.local.", diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 8f46407aff6..0bd5b5f59d0 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Yeelight config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -465,8 +466,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -535,8 +536,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -603,8 +604,8 @@ async def test_discovered_by_dhcp_or_homekit(hass: HomeAssistant, source, data) ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -827,8 +828,8 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index a6ff257d78c..54406bb1b4d 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -859,6 +859,7 @@ async def test_info_from_service_with_link_local_address_first( service_info.addresses = ["169.254.12.3", "192.168.66.12"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["169.254.12.3", "192.168.66.12"] async def test_info_from_service_with_unspecified_address_first( @@ -870,6 +871,7 @@ async def test_info_from_service_with_unspecified_address_first( service_info.addresses = ["0.0.0.0", "192.168.66.12"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["0.0.0.0", "192.168.66.12"] async def test_info_from_service_with_unspecified_address_only( @@ -892,6 +894,7 @@ async def test_info_from_service_with_link_local_address_second( service_info.addresses = ["192.168.66.12", "169.254.12.3"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["192.168.66.12", "169.254.12.3"] async def test_info_from_service_with_link_local_address_only( diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 981ca2aca38..9ec8048ea03 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for ZHA config flow.""" import copy from datetime import timedelta +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch import uuid @@ -142,8 +143,8 @@ def com_port(device="/dev/ttyUSB1234"): async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", name="tube", port=6053, @@ -192,8 +193,8 @@ async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> None: """Test zeroconf flow -- zigate radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="_zigate-zigbee-gateway._tcp.local.", name="any", port=1234, @@ -247,8 +248,8 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: """Test zeroconf flow -- efr32 radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="efr32._esphomelib._tcp.local.", name="efr32", port=1234, @@ -310,8 +311,8 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.22", - addresses=["192.168.1.22"], + ip_address=ip_address("192.168.1.22"), + ip_addresses=[ip_address("192.168.1.22")], hostname="tube_zb_gw_cc2652p2_poe.local.", name="mock_name", port=6053, @@ -343,8 +344,8 @@ async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.22", - addresses=["192.168.1.22"], + ip_address=ip_address("192.168.1.22"), + ip_addresses=[ip_address("192.168.1.22")], hostname="tube_zb_gw_cc2652p2_poe.local.", name="mock_name", port=6053, @@ -365,8 +366,8 @@ async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> async def test_discovery_confirm_final_abort_if_entries(hass: HomeAssistant) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", name="tube", port=6053, @@ -698,8 +699,8 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non async def test_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="_tube_zb_gw._tcp.local.", name="mock_name", port=6053, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 73dd82d5f4b..a051f398d8c 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Generator from copy import copy +from ipaddress import ip_address from unittest.mock import DEFAULT, MagicMock, call, patch import aiohttp @@ -2672,8 +2673,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - host="localhost", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=3000, @@ -2697,7 +2698,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["type"] == "create_entry" assert result["title"] == TITLE assert result["data"] == { - "url": "ws://localhost:3000", + "url": "ws://127.0.0.1:3000", "usb_path": None, "s0_legacy_key": None, "s2_access_control_key": None, diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index 7d1919a8698..145cecd58c8 100644 --- a/tests/components/zwave_me/test_config_flow.py +++ b/tests/components/zwave_me/test_config_flow.py @@ -1,4 +1,5 @@ """Test the zwave_me config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -10,10 +11,10 @@ from homeassistant.data_entry_flow import FlowResult, FlowResultType from tests.common import MockConfigEntry MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="ws://192.168.1.14", + ip_address=ip_address("192.168.1.14"), + ip_addresses=[ip_address("192.168.1.14")], hostname="mock_hostname", name="mock_name", - addresses=["192.168.1.14"], port=1234, properties={ "deviceid": "aa:bb:cc:dd:ee:ff", From c099ec19f22a19205f1268e3bd3e8b8887b1965e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 19 Sep 2023 18:30:18 +0000 Subject: [PATCH 647/984] Add missing translations for Shelly event type states (#100608) Add missing translations for event type --- homeassistant/components/shelly/event.py | 1 + homeassistant/components/shelly/strings.json | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index e37b4cdcdac..2abedf3cf9a 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -37,6 +37,7 @@ class ShellyEventDescription(EventEntityDescription): RPC_EVENT: Final = ShellyEventDescription( key="input", + translation_key="input", device_class=EventDeviceClass.BUTTON, event_types=list(RPC_INPUTS_EVENTS_TYPES), removal_condition=lambda config, status, key: not is_rpc_momentary_input( diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index dcdfa6d7987..d2e72ee81da 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -98,6 +98,22 @@ } } }, + "event": { + "input": { + "state_attributes": { + "event_type": { + "state": { + "btn_down": "Button down", + "btn_up": "Button up", + "double_push": "Double push", + "long_push": "Long push", + "single_push": "Single push", + "triple_push": "Triple push" + } + } + } + } + }, "sensor": { "operation": { "state": { From f1a70189acc4e01ac4e91b23782825f978f14579 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 19 Sep 2023 22:14:21 +0200 Subject: [PATCH 648/984] Clean-up Minecraft Server tests (#100615) Remove patching of getmac, fix typo --- tests/components/minecraft_server/test_config_flow.py | 4 ++-- tests/components/minecraft_server/test_init.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index c4d8c72e32d..463a78b4680 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -149,7 +149,7 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with an IPv4 address.""" - with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"), patch( + with patch( "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( @@ -168,7 +168,7 @@ async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: async def test_connection_succeeded_with_ip6(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with an IPv6 address.""" - with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"), patch( + with patch( "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 5bdce5ed9b7..77b6901a0a2 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -33,7 +33,7 @@ BINARY_SENSOR_KEYS = {"v1": "Status", "v2": "status"} async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: - """Test entry migratiion from version 1 to 2.""" + """Test entry migration from version 1 to 2.""" # Create mock config entry. config_entry_v1 = MockConfigEntry( From 1d5905b591e612c25615ba6cb7510c3964a96bee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 01:08:32 +0200 Subject: [PATCH 649/984] Use is for UNDEFINED check in async_update_entry (#100599) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 046f403642e..f4e61bfffbd 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1348,7 +1348,7 @@ class ConfigEntries: ("pref_disable_new_entities", pref_disable_new_entities), ("pref_disable_polling", pref_disable_polling), ): - if value == UNDEFINED or getattr(entry, attr) == value: + if value is UNDEFINED or getattr(entry, attr) == value: continue setattr(entry, attr, value) From 6c095a963dd1b914f5b0aca0d7a7a7dfbc3904d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 01:08:58 +0200 Subject: [PATCH 650/984] Switch config flows use newer zeroconf methods to check IP Addresses (#100568) --- homeassistant/components/apple_tv/config_flow.py | 5 ++--- homeassistant/components/baf/config_flow.py | 5 ++--- homeassistant/components/hue/config_flow.py | 3 +-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 8a2130faca0..6a85ea1d1a8 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -26,7 +26,6 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) -from homeassistant.util.network import is_ipv6_address from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN @@ -184,9 +183,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle device found via zeroconf.""" - host = discovery_info.host - if is_ipv6_address(host): + if discovery_info.ip_address.version == 6: return self.async_abort(reason="ipv6_not_supported") + host = discovery_info.host self._async_abort_entries_match({CONF_ADDRESS: host}) service_type = discovery_info.type[:-1] # Remove leading . name = discovery_info.name.replace(f".{service_type}.", "") diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index bbae3914533..9edb23abcf8 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -14,7 +14,6 @@ from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import CONF_IP_ADDRESS from homeassistant.data_entry_flow import FlowResult -from homeassistant.util.network import is_ipv6_address from .const import DOMAIN, RUN_TIMEOUT from .models import BAFDiscovery @@ -49,10 +48,10 @@ class BAFFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" + if discovery_info.ip_address.version == 6: + return self.async_abort(reason="ipv6_not_supported") properties = discovery_info.properties ip_address = discovery_info.host - if is_ipv6_address(ip_address): - return self.async_abort(reason="ipv6_not_supported") uuid = properties["uuid"] model = properties["model"] name = properties["name"] diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 9c8dda94c94..0957329abb0 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -22,7 +22,6 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) -from homeassistant.util.network import is_ipv6_address from .const import ( CONF_ALLOW_HUE_GROUPS, @@ -219,7 +218,7 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): host is already configured and delegate to the import step if not. """ # Ignore if host is IPv6 - if is_ipv6_address(discovery_info.host): + if discovery_info.ip_address.version == 6: return self.async_abort(reason="invalid_host") # abort if we already have exactly this bridge id/host From bd9bab000e1e4dbe651290ebba1600f6949e91a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 20 Sep 2023 01:44:35 +0100 Subject: [PATCH 651/984] Add integration for IKEA Idasen Desk (#99173) Co-authored-by: J. Nick Koston --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/ikea.json | 2 +- .../components/idasen_desk/__init__.py | 94 +++++++ .../components/idasen_desk/config_flow.py | 115 +++++++++ homeassistant/components/idasen_desk/const.py | 6 + homeassistant/components/idasen_desk/cover.py | 101 ++++++++ .../components/idasen_desk/manifest.json | 15 ++ .../components/idasen_desk/strings.json | 22 ++ homeassistant/generated/bluetooth.py | 4 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/idasen_desk/__init__.py | 51 ++++ tests/components/idasen_desk/conftest.py | 49 ++++ .../idasen_desk/test_config_flow.py | 230 ++++++++++++++++++ tests/components/idasen_desk/test_cover.py | 82 +++++++ tests/components/idasen_desk/test_init.py | 55 +++++ 20 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/idasen_desk/__init__.py create mode 100644 homeassistant/components/idasen_desk/config_flow.py create mode 100644 homeassistant/components/idasen_desk/const.py create mode 100644 homeassistant/components/idasen_desk/cover.py create mode 100644 homeassistant/components/idasen_desk/manifest.json create mode 100644 homeassistant/components/idasen_desk/strings.json create mode 100644 tests/components/idasen_desk/__init__.py create mode 100644 tests/components/idasen_desk/conftest.py create mode 100644 tests/components/idasen_desk/test_config_flow.py create mode 100644 tests/components/idasen_desk/test_cover.py create mode 100644 tests/components/idasen_desk/test_init.py diff --git a/.strict-typing b/.strict-typing index 56c7bf248e1..97af46884c4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -180,6 +180,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* +homeassistant.components.idasen_desk.* homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* diff --git a/CODEOWNERS b/CODEOWNERS index b3d2889b108..fe6aba2e5bb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -569,6 +569,8 @@ build.json @home-assistant/supervisor /tests/components/ibeacon/ @bdraco /homeassistant/components/icloud/ @Quentame @nzapponi /tests/components/icloud/ @Quentame @nzapponi +/homeassistant/components/idasen_desk/ @abmantis +/tests/components/idasen_desk/ @abmantis /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte /homeassistant/components/image/ @home-assistant/core diff --git a/homeassistant/brands/ikea.json b/homeassistant/brands/ikea.json index 702a59ad4d1..dee69001add 100644 --- a/homeassistant/brands/ikea.json +++ b/homeassistant/brands/ikea.json @@ -1,5 +1,5 @@ { "domain": "ikea", "name": "IKEA", - "integrations": ["symfonisk", "tradfri"] + "integrations": ["symfonisk", "tradfri", "idasen_desk"] } diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py new file mode 100644 index 00000000000..5fd23ba47e0 --- /dev/null +++ b/homeassistant/components/idasen_desk/__init__.py @@ -0,0 +1,94 @@ +"""The IKEA Idasen Desk integration.""" +from __future__ import annotations + +import logging + +from attr import dataclass +from bleak import BleakError +from idasen_ha import Desk + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_NAME, + CONF_ADDRESS, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.COVER] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeskData: + """Data for the Idasen Desk integration.""" + + desk: Desk + address: str + device_info: DeviceInfo + coordinator: DataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up IKEA Idasen from a config entry.""" + address: str = entry.data[CONF_ADDRESS].upper() + + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=entry.title, + ) + + desk = Desk(coordinator.async_set_updated_data) + device_info = DeviceInfo( + name=entry.title, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData( + desk, address, device_info, coordinator + ) + + ble_device = bluetooth.async_ble_device_from_address( + hass, address, connectable=True + ) + try: + await desk.connect(ble_device) + except (TimeoutError, BleakError) as ex: + raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + async def _async_stop(event: Event) -> None: + """Close the connection.""" + await desk.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + ) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + if entry.title != data.device_info[ATTR_NAME]: + await hass.config_entries.async_reload(entry.entry_id) + + +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): + data: DeskData = hass.data[DOMAIN].pop(entry.entry_id) + await data.desk.disconnect() + + return unload_ok diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py new file mode 100644 index 00000000000..f56446396d2 --- /dev/null +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow for Idasen Desk integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from bleak import BleakError +from bluetooth_data_tools import human_readable_name +from idasen_ha import Desk +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, EXPECTED_SERVICE_UUID + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Idasen Desk integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + discovery_info = self._discovered_devices[address] + local_name = discovery_info.name + await self.async_set_unique_id( + discovery_info.address, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + desk = Desk(None) + try: + await desk.connect(discovery_info.device, monitor_height=False) + except TimeoutError as err: + _LOGGER.exception("TimeoutError", exc_info=err) + errors["base"] = "cannot_connect" + except BleakError as err: + _LOGGER.exception("BleakError", exc_info=err) + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + await desk.disconnect() + return self.async_create_entry( + title=local_name, + data={ + CONF_ADDRESS: discovery_info.address, + }, + ) + + if discovery := self._discovery_info: + self._discovered_devices[discovery.address] = discovery + else: + current_addresses = self._async_current_ids() + for discovery in async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or EXPECTED_SERVICE_UUID not in discovery.service_uuids + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: f"{service_info.name} ({service_info.address})" + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/idasen_desk/const.py b/homeassistant/components/idasen_desk/const.py new file mode 100644 index 00000000000..0d37d77307b --- /dev/null +++ b/homeassistant/components/idasen_desk/const.py @@ -0,0 +1,6 @@ +"""Constants for the Idasen Desk integration.""" + + +DOMAIN = "idasen_desk" + +EXPECTED_SERVICE_UUID = "99fa0001-338a-1024-8a49-009c0215f78a" diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py new file mode 100644 index 00000000000..c1d1bb48fd8 --- /dev/null +++ b/homeassistant/components/idasen_desk/cover.py @@ -0,0 +1,101 @@ +"""Idasen Desk integration cover platform.""" +from __future__ import annotations + +import logging +from typing import Any + +from idasen_ha import Desk + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DeskData +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the cover platform for Idasen Desk.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [IdasenDeskCover(data.desk, data.address, data.device_info, data.coordinator)] + ) + + +class IdasenDeskCover(CoordinatorEntity, CoverEntity): + """Representation of Idasen Desk device.""" + + _attr_device_class = CoverDeviceClass.DAMPER + _attr_icon = "mdi:desk" + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + def __init__( + self, + desk: Desk, + address: str, + device_info: DeviceInfo, + coordinator: DataUpdateCoordinator, + ) -> None: + """Initialize an Idasen Desk cover.""" + super().__init__(coordinator) + self._desk = desk + self._attr_name = device_info[ATTR_NAME] + self._attr_unique_id = address + self._attr_device_info = device_info + + self._attr_current_cover_position = self._desk.height_percent + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._desk.is_connected is True + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + return self.current_cover_position == 0 + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self._desk.move_down() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._desk.move_up() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._desk.stop() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover shutter to a specific position.""" + await self._desk.move_to(int(kwargs[ATTR_POSITION])) + + @callback + def _handle_coordinator_update(self, *args: Any) -> None: + """Handle data update.""" + self._attr_current_cover_position = self._desk.height_percent + self.async_write_ha_state() diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json new file mode 100644 index 00000000000..f77e0c22373 --- /dev/null +++ b/homeassistant/components/idasen_desk/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "idasen_desk", + "name": "IKEA Idasen Desk", + "bluetooth": [ + { + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a" + } + ], + "codeowners": ["@abmantis"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/idasen_desk", + "iot_class": "local_push", + "requirements": ["idasen-ha==1.4"] +} diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json new file mode 100644 index 00000000000..e2be7e6deff --- /dev/null +++ b/homeassistant/components/idasen_desk/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "data": { + "address": "Bluetooth address" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "not_supported": "Device not supported", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 7b0aa78d69e..5784667bc67 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -213,6 +213,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ ], "manufacturer_id": 76, }, + { + "domain": "idasen_desk", + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a", + }, { "connectable": False, "domain": "inkbird", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0d20e80317c..3f37f3a19df 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -210,6 +210,7 @@ FLOWS = { "iaqualink", "ibeacon", "icloud", + "idasen_desk", "ifttt", "imap", "inkbird", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d1efd527b69..966cf186346 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2578,6 +2578,12 @@ "config_flow": true, "iot_class": "local_polling", "name": "IKEA TR\u00c5DFRI" + }, + "idasen_desk": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "IKEA Idasen Desk" } } }, diff --git a/mypy.ini b/mypy.ini index d2c2a66d738..67390ef2ddf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1562,6 +1562,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.idasen_desk.*] +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.image.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2a9f39baf4c..49806f29942 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1042,6 +1042,9 @@ ical==5.0.1 # homeassistant.components.ping icmplib==3.0 +# homeassistant.components.idasen_desk +idasen-ha==1.4 + # homeassistant.components.network ifaddr==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2bac718dc57..51195c7cecd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -819,6 +819,9 @@ ical==5.0.1 # homeassistant.components.ping icmplib==3.0 +# homeassistant.components.idasen_desk +idasen-ha==1.4 + # homeassistant.components.network ifaddr==0.2.0 diff --git a/tests/components/idasen_desk/__init__.py b/tests/components/idasen_desk/__init__.py new file mode 100644 index 00000000000..7e8becc4689 --- /dev/null +++ b/tests/components/idasen_desk/__init__.py @@ -0,0 +1,51 @@ +"""Tests for the IKEA Idasen Desk integration.""" + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + +IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Desk 1234", + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=["99fa0001-338a-1024-8a49-009c0215f78a"], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Desk 1234"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) + +NOT_IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Not Desk", + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=[], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Not Desk"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the IKEA Idasen Desk integration in Home Assistant.""" + entry = MockConfigEntry( + title="Test", + domain=DOMAIN, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py new file mode 100644 index 00000000000..736bc6346ce --- /dev/null +++ b/tests/components/idasen_desk/conftest.py @@ -0,0 +1,49 @@ +"""IKEA Idasen Desk fixtures.""" + +from collections.abc import Callable +from unittest import mock +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=False) +def mock_desk_api(): + """Set up idasen desk API fixture.""" + with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched: + mock_desk = MagicMock() + + def mock_init(update_callback: Callable[[int | None], None] | None): + mock_desk.trigger_update_callback = update_callback + return mock_desk + + desk_patched.side_effect = mock_init + + async def mock_connect(ble_device, monitor_height: bool = True): + mock_desk.is_connected = True + + async def mock_move_to(height: float): + mock_desk.height_percent = height + mock_desk.trigger_update_callback(height) + + async def mock_move_up(): + await mock_move_to(100) + + async def mock_move_down(): + await mock_move_to(0) + + mock_desk.connect = AsyncMock(side_effect=mock_connect) + mock_desk.disconnect = AsyncMock() + mock_desk.move_to = AsyncMock(side_effect=mock_move_to) + mock_desk.move_up = AsyncMock(side_effect=mock_move_up) + mock_desk.move_down = AsyncMock(side_effect=mock_move_down) + mock_desk.stop = AsyncMock() + mock_desk.height_percent = 60 + mock_desk.is_moving = False + + yield mock_desk diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py new file mode 100644 index 00000000000..8635e5bfddc --- /dev/null +++ b/tests/components/idasen_desk/test_config_flow.py @@ -0,0 +1,230 @@ +"""Test the IKEA Idasen Desk config flow.""" +from unittest.mock import patch + +from bleak import BleakError +import pytest + +from homeassistant import config_entries +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import IDASEN_DISCOVERY_INFO, NOT_IDASEN_DISCOVERY_INFO + +from tests.common import MockConfigEntry + + +async def test_user_step_success(hass: HomeAssistant) -> None: + """Test user step success path.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: + """Test user step with no devices found.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: + """Test user step with only existing devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + unique_id=IDASEN_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +async def test_user_step_cannot_connect( + hass: HomeAssistant, exception: Exception +) -> None: + """Test user step and we cannot connect.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + side_effect=exception, + ), patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == IDASEN_DISCOVERY_INFO.name + assert result3["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: + """Test user step with an unknown exception.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + side_effect=RuntimeError, + ), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect", + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + ), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect", + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == IDASEN_DISCOVERY_INFO.name + assert result3["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_bluetooth_step_success(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IDASEN_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py new file mode 100644 index 00000000000..a9c74be7081 --- /dev/null +++ b/tests/components/idasen_desk/test_cover.py @@ -0,0 +1,82 @@ +"""Test the IKEA Idasen Desk cover.""" +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, +) +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_cover_available( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test cover available property.""" + entity_id = "cover.test" + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + mock_desk_api.is_connected = False + mock_desk_api.trigger_update_callback(None) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_state", "expected_position"), + [ + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 100}, STATE_OPEN, 100), + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 0}, STATE_CLOSED, 0), + (SERVICE_OPEN_COVER, {}, STATE_OPEN, 100), + (SERVICE_CLOSE_COVER, {}, STATE_CLOSED, 0), + (SERVICE_STOP_COVER, {}, STATE_OPEN, 60), + ], +) +async def test_cover_services( + hass: HomeAssistant, + mock_desk_api: MagicMock, + service: str, + service_data: dict[str, Any], + expected_state: str, + expected_position: int, +) -> None: + """Test cover services.""" + entity_id = "cover.test" + await init_integration(hass) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **service_data}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == expected_state + assert state.attributes[ATTR_CURRENT_POSITION] == expected_position diff --git a/tests/components/idasen_desk/test_init.py b/tests/components/idasen_desk/test_init.py new file mode 100644 index 00000000000..e596f0fe000 --- /dev/null +++ b/tests/components/idasen_desk/test_init.py @@ -0,0 +1,55 @@ +"""Test the IKEA Idasen Desk init.""" +from unittest.mock import AsyncMock, MagicMock + +from bleak import BleakError +import pytest + +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_setup_and_shutdown( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test setup.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + mock_desk_api.connect.assert_called_once() + mock_desk_api.is_connected = True + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + +@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +async def test_setup_connect_exception( + hass: HomeAssistant, mock_desk_api: MagicMock, exception: Exception +) -> None: + """Test setup with an connection exception.""" + mock_desk_api.connect = AsyncMock(side_effect=exception) + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: + """Test successful unload of entry.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + mock_desk_api.connect.assert_called_once() + mock_desk_api.is_connected = True + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 From 03af4679182f5eff9fa8f6bca0620e39ede4c151 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Sep 2023 08:47:20 +0200 Subject: [PATCH 652/984] Move renson coordinator to its own file (#100610) --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 35 +--------------- .../components/renson/binary_sensor.py | 2 +- .../components/renson/coordinator.py | 41 +++++++++++++++++++ homeassistant/components/renson/entity.py | 2 +- homeassistant/components/renson/fan.py | 2 +- homeassistant/components/renson/number.py | 2 +- homeassistant/components/renson/sensor.py | 3 +- 8 files changed, 49 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/renson/coordinator.py diff --git a/.coveragerc b/.coveragerc index 73ae1d1a466..2a75526e63a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1011,6 +1011,7 @@ omit = homeassistant/components/rainmachine/util.py homeassistant/components/renson/__init__.py homeassistant/components/renson/const.py + homeassistant/components/renson/coordinator.py homeassistant/components/renson/entity.py homeassistant/components/renson/sensor.py homeassistant/components/renson/fan.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 7ce143d8a21..231e63bfc25 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -1,11 +1,7 @@ """The Renson integration.""" from __future__ import annotations -import asyncio from dataclasses import dataclass -from datetime import timedelta -import logging -from typing import Any from renson_endura_delta.renson import RensonVentilation @@ -13,11 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import RensonCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -62,30 +56,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class RensonCoordinator(DataUpdateCoordinator): - """Data update coordinator for Renson.""" - - def __init__( - self, - name: str, - hass: HomeAssistant, - api: RensonVentilation, - update_interval=timedelta(seconds=30), - ) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name=name, - # Polling interval. Will only be polled if there are subscribers. - update_interval=update_interval, - ) - self.api = api - - async def _async_update_data(self) -> dict[str, Any]: - """Fetch data from API endpoint.""" - async with asyncio.timeout(30): - return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index cad8b92c0c3..39c2b1b883d 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -25,8 +25,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity diff --git a/homeassistant/components/renson/coordinator.py b/homeassistant/components/renson/coordinator.py new file mode 100644 index 00000000000..924a3b765f5 --- /dev/null +++ b/homeassistant/components/renson/coordinator.py @@ -0,0 +1,41 @@ +"""DataUpdateCoordinator for the renson integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class RensonCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Data update coordinator for Renson.""" + + def __init__( + self, + name: str, + hass: HomeAssistant, + api: RensonVentilation, + update_interval=timedelta(seconds=30), + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=name, + # Polling interval. Will only be polled if there are subscribers. + update_interval=update_interval, + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from API endpoint.""" + async with asyncio.timeout(30): + return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py index 245b55d6611..9bb2c27b112 100644 --- a/homeassistant/components/renson/entity.py +++ b/homeassistant/components/renson/entity.py @@ -12,8 +12,8 @@ from renson_endura_delta.renson import RensonVentilation from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator class RensonEntity(CoordinatorEntity[RensonCoordinator]): diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 0fe639d40ec..da6850859a6 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -18,8 +18,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py index bf33b75c9e3..344fa3ff0bd 100644 --- a/homeassistant/components/renson/number.py +++ b/homeassistant/components/renson/number.py @@ -16,8 +16,8 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 661ab82f373..b729e2969d6 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -46,8 +46,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator, RensonData +from . import RensonData from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity OPTIONS_MAPPING = { From 7af62c35f505837966dc9af78730dcbf22d1727a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Sep 2023 08:59:49 +0200 Subject: [PATCH 653/984] Move faa_delays coordinator to its own file (#100548) --- .coveragerc | 1 + .../components/faa_delays/__init__.py | 33 +---------------- .../components/faa_delays/coordinator.py | 35 +++++++++++++++++++ 3 files changed, 37 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/faa_delays/coordinator.py diff --git a/.coveragerc b/.coveragerc index 2a75526e63a..ac08240fd0f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -357,6 +357,7 @@ omit = homeassistant/components/ezviz/update.py homeassistant/components/faa_delays/__init__.py homeassistant/components/faa_delays/binary_sensor.py + homeassistant/components/faa_delays/coordinator.py homeassistant/components/familyhub/camera.py homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py index b165492d076..3606da33499 100644 --- a/homeassistant/components/faa_delays/__init__.py +++ b/homeassistant/components/faa_delays/__init__.py @@ -1,20 +1,10 @@ """The FAA Delays integration.""" -import asyncio -from datetime import timedelta -import logging - -from aiohttp import ClientConnectionError -from faadelays import Airport - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import FAADataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR] @@ -40,24 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class FAADataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching FAA API data from a single endpoint.""" - - def __init__(self, hass, code): - """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): - try: - async with asyncio.timeout(10): - await self.data.update() - except ClientConnectionError as err: - raise UpdateFailed(err) from err - return self.data diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py new file mode 100644 index 00000000000..f2aefdada66 --- /dev/null +++ b/homeassistant/components/faa_delays/coordinator.py @@ -0,0 +1,35 @@ +"""DataUpdateCoordinator for faa_delays integration.""" +import asyncio +from datetime import timedelta +import logging + +from aiohttp import ClientConnectionError +from faadelays import Airport + +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FAADataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching FAA API data from a single endpoint.""" + + def __init__(self, hass, code): + """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): + try: + async with asyncio.timeout(10): + await self.data.update() + except ClientConnectionError as err: + raise UpdateFailed(err) from err + return self.data From 33f748493e19a9a900d8ac8cf28cbc2ed21081d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 09:49:16 +0200 Subject: [PATCH 654/984] Update enphase_envoy zeroconf checks to use stdlib ipaddress methods (#100624) --- homeassistant/components/enphase_envoy/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index b41d29626e7..999542ee2a5 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -15,7 +15,6 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.util.network import is_ipv4_address from .const import DOMAIN, INVALID_AUTH_ERRORS @@ -90,7 +89,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" - if not is_ipv4_address(discovery_info.host): + if discovery_info.ip_address.version != 4: return self.async_abort(reason="not_ipv4_address") serial = discovery_info.properties["serialnum"] self.protovers = discovery_info.properties.get("protovers") From 06c7f0959c3edb6920d6507811d6571ddd586ca7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 11:54:24 +0200 Subject: [PATCH 655/984] Update dhcp to use stdlib ipaddress methods (#100625) --- homeassistant/components/dhcp/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 29b25d0781b..c3705dad3dd 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -58,7 +58,6 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import DHCPMatcher, async_get_dhcp from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.network import is_invalid, is_link_local, is_loopback from .const import DOMAIN @@ -162,9 +161,9 @@ class WatcherBase(ABC): made_ip_address = make_ip_address(ip_address) if ( - is_link_local(made_ip_address) - or is_loopback(made_ip_address) - or is_invalid(made_ip_address) + made_ip_address.is_link_local + or made_ip_address.is_loopback + or made_ip_address.is_unspecified ): # Ignore self assigned addresses, loopback, invalid return From d675825b5ae697038506a531c9aa31e0ebf4a45f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 11:55:51 +0200 Subject: [PATCH 656/984] Avoid double lookups with data_entry_flow indices (#100627) --- homeassistant/data_entry_flow.py | 35 +++++++++++++------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 467fc3b5228..63cbfda5b9b 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -138,8 +138,8 @@ class FlowManager(abc.ABC): self.hass = hass self._preview: set[str] = set() self._progress: dict[str, FlowHandler] = {} - self._handler_progress_index: dict[str, set[str]] = {} - self._init_data_process_index: dict[type, set[str]] = {} + self._handler_progress_index: dict[str, set[FlowHandler]] = {} + self._init_data_process_index: dict[type, set[FlowHandler]] = {} @abc.abstractmethod async def async_create_flow( @@ -221,9 +221,9 @@ class FlowManager(abc.ABC): """Return flows in progress init matching by data type as a partial FlowResult.""" return _async_flow_handler_to_flow_result( ( - self._progress[flow_id] - for flow_id in self._init_data_process_index.get(init_data_type, {}) - if matcher(self._progress[flow_id].init_data) + progress + for progress in self._init_data_process_index.get(init_data_type, set()) + if matcher(progress.init_data) ), include_uninitialized, ) @@ -237,18 +237,13 @@ class FlowManager(abc.ABC): If match_context is specified, only return flows with a context that is a superset of match_context. """ - match_context_items = match_context.items() if match_context else None + if not match_context: + return list(self._handler_progress_index.get(handler, [])) + match_context_items = match_context.items() return [ progress - for flow_id in self._handler_progress_index.get(handler, {}) - if (progress := self._progress[flow_id]) - and ( - not match_context_items - or ( - (context := progress.context) - and match_context_items <= context.items() - ) - ) + for progress in self._handler_progress_index.get(handler, set()) + if match_context_items <= progress.context.items() ] async def async_init( @@ -348,22 +343,20 @@ class FlowManager(abc.ABC): """Add a flow to in progress.""" if flow.init_data is not None: init_data_type = type(flow.init_data) - self._init_data_process_index.setdefault(init_data_type, set()).add( - flow.flow_id - ) + self._init_data_process_index.setdefault(init_data_type, set()).add(flow) self._progress[flow.flow_id] = flow - self._handler_progress_index.setdefault(flow.handler, set()).add(flow.flow_id) + self._handler_progress_index.setdefault(flow.handler, set()).add(flow) @callback def _async_remove_flow_from_index(self, flow: FlowHandler) -> None: """Remove a flow from in progress.""" if flow.init_data is not None: init_data_type = type(flow.init_data) - self._init_data_process_index[init_data_type].remove(flow.flow_id) + self._init_data_process_index[init_data_type].remove(flow) if not self._init_data_process_index[init_data_type]: del self._init_data_process_index[init_data_type] handler = flow.handler - self._handler_progress_index[handler].remove(flow.flow_id) + self._handler_progress_index[handler].remove(flow) if not self._handler_progress_index[handler]: del self._handler_progress_index[handler] From 7014ed34534fd4373e394ae436b8ed1a9d26d68e Mon Sep 17 00:00:00 2001 From: Robin Li Date: Wed, 20 Sep 2023 07:53:05 -0400 Subject: [PATCH 657/984] Fix ecobee aux_heat_off always returns to HEAT (#100630) --- homeassistant/components/ecobee/climate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index b18f646add7..e1253b585ac 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -326,6 +326,7 @@ class Thermostat(ClimateEntity): self._attr_unique_id = self.thermostat["identifier"] self.vacation = None self._last_active_hvac_mode = HVACMode.HEAT_COOL + self._last_hvac_mode_before_aux_heat = HVACMode.HEAT_COOL self._attr_hvac_modes = [] if self.settings["heatStages"] or self.settings["hasHeatPump"]: @@ -541,13 +542,14 @@ class Thermostat(ClimateEntity): def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" _LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat") + self._last_hvac_mode_before_aux_heat = self.hvac_mode self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY) self.update_without_throttle = True def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" _LOGGER.debug("Setting HVAC mode to last mode to disable aux heat") - self.set_hvac_mode(self._last_active_hvac_mode) + self.set_hvac_mode(self._last_hvac_mode_before_aux_heat) self.update_without_throttle = True def set_preset_mode(self, preset_mode: str) -> None: From 8b5129a7d92dcae8bff6a34f73484592e7a97ba2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 13:58:34 +0200 Subject: [PATCH 658/984] Bump dbus-fast to 2.9.0 (#100638) --- 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 54f10fbc0c7..56b06cd9d35 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", - "dbus-fast==2.7.0" + "dbus-fast==2.9.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index df72b224c63..77327fc6c80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.7.0 +dbus-fast==2.9.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.71.0 diff --git a/requirements_all.txt b/requirements_all.txt index 49806f29942..0ef33a05476 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.7.0 +dbus-fast==2.9.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51195c7cecd..610e875d2d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.7.0 +dbus-fast==2.9.0 # homeassistant.components.debugpy debugpy==1.8.0 From 6f8734167feea9b839ea0d45bceae79fd329f5c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 16:19:53 +0200 Subject: [PATCH 659/984] Bump SQLAlchemy to 2.0.21 (#99745) --- .github/workflows/wheels.yml | 8 +- .../components/recorder/history/legacy.py | 2 +- .../components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../recorder/test_migration_from_schema_32.py | 101 +++++++++++------- 8 files changed, 70 insertions(+), 51 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6d947f51aca..7636d628e41 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -56,7 +56,7 @@ jobs: echo "CI_BUILD=1" echo "ENABLE_HEADLESS=1" - # Use C-Extension for sqlalchemy + # Use C-Extension for SQLAlchemy echo "REQUIRE_SQLALCHEMY_CEXT=1" ) > .env_file @@ -186,7 +186,7 @@ jobs: 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" - skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +200,7 @@ jobs: 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" - skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +214,7 @@ jobs: 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" - skip-binary: aiohttp;grpcio;sqlalchemy;protobuf + skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 191c74ac0d4..2e1b02a8b64 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -50,7 +50,7 @@ _BASE_STATES = ( States.last_changed_ts, States.last_updated_ts, ) -_BASE_STATES_NO_LAST_CHANGED = ( # type: ignore[var-annotated] +_BASE_STATES_NO_LAST_CHANGED = ( States.entity_id, States.state, literal(value=None).label("last_changed_ts"), diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 63b19cdb3bf..f40797fe38c 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.15", + "SQLAlchemy==2.0.21", "fnv-hash-fast==0.4.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 44de8fc6923..7424807c804 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.15"] + "requirements": ["SQLAlchemy==2.0.21"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 77327fc6c80..b1ad7f7a3c5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.15 +SQLAlchemy==2.0.21 typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0ef33a05476..8eb3b324064 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -129,7 +129,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.15 +SQLAlchemy==2.0.21 # homeassistant.components.travisci TravisPy==0.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 610e875d2d3..d6d9bb9242e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.15 +SQLAlchemy==2.0.21 # homeassistant.components.onvif WSDiscovery==2.0.0 diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index cdf930fde26..e007d2408dd 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -39,6 +39,12 @@ SCHEMA_MODULE = "tests.components.recorder.db_schema_32" ORIG_TZ = dt_util.DEFAULT_TIME_ZONE +async def _async_wait_migration_done(hass: HomeAssistant) -> None: + """Wait for the migration to be done.""" + await recorder.get_instance(hass).async_block_till_done() + await async_recorder_block_till_done(hass) + + def _create_engine_test(*args, **kwargs): """Test version of create_engine that initializes with old schema. @@ -101,6 +107,8 @@ async def test_migrate_events_context_ids( """Test we can migrate old uuid context ids and ulid context ids to binary format.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] test_uuid = uuid.uuid4() uuid_hex = test_uuid.hex @@ -110,7 +118,7 @@ async def test_migrate_events_context_ids( with session_scope(hass=hass) as session: session.add_all( ( - Events( + old_db_schema.Events( event_type="old_uuid_context_id_event", event_data=None, origin_idx=0, @@ -123,7 +131,7 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="empty_context_id_event", event_data=None, origin_idx=0, @@ -136,7 +144,7 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="ulid_context_id_event", event_data=None, origin_idx=0, @@ -149,7 +157,7 @@ async def test_migrate_events_context_ids( context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="invalid_context_id_event", event_data=None, origin_idx=0, @@ -162,7 +170,7 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="garbage_context_id_event", event_data=None, origin_idx=0, @@ -175,7 +183,7 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="event_with_garbage_context_id_no_time_fired_ts", event_data=None, origin_idx=0, @@ -196,10 +204,12 @@ async def test_migrate_events_context_ids( await async_wait_recording_done(hass) now = dt_util.utcnow() expected_ulid_fallback_start = ulid_to_bytes(ulid_at_time(now.timestamp()))[0:6] + await _async_wait_migration_done(hass) + with freeze_time(now): # This is a threadsafe way to add a task to the recorder instance.queue_task(EventsContextIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _object_as_dict(obj): return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} @@ -304,6 +314,8 @@ async def test_migrate_states_context_ids( """Test we can migrate old uuid context ids and ulid context ids to binary format.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] test_uuid = uuid.uuid4() uuid_hex = test_uuid.hex @@ -313,7 +325,7 @@ async def test_migrate_states_context_ids( with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="state.old_uuid_context_id", last_updated_ts=1477721632.452529, context_id=uuid_hex, @@ -323,7 +335,7 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.empty_context_id", last_updated_ts=1477721632.552529, context_id=None, @@ -333,7 +345,7 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.ulid_context_id", last_updated_ts=1477721632.552529, context_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", @@ -343,7 +355,7 @@ async def test_migrate_states_context_ids( context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.invalid_context_id", last_updated_ts=1477721632.552529, context_id="invalid", @@ -353,7 +365,7 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.garbage_context_id", last_updated_ts=1477721632.552529, context_id="adapt_lgt:b'5Cf*':interval:b'0R'", @@ -363,7 +375,7 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.human_readable_uuid_context_id", last_updated_ts=1477721632.552529, context_id="0ae29799-ee4e-4f45-8116-f582d7d3ee65", @@ -380,7 +392,7 @@ async def test_migrate_states_context_ids( await async_wait_recording_done(hass) instance.queue_task(StatesContextIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _object_as_dict(obj): return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} @@ -489,22 +501,24 @@ async def test_migrate_event_type_ids( """Test we can migrate event_types to the EventTypes table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add_all( ( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1677721632.452529, ), - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1677721632.552529, ), - Events( + old_db_schema.Events( event_type="event_type_two", origin_idx=0, time_fired_ts=1677721632.552529, @@ -517,7 +531,7 @@ async def test_migrate_event_type_ids( await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EventTypeIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: @@ -570,22 +584,24 @@ async def test_migrate_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_2", last_updated_ts=2.252529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_1", last_updated_ts=3.152529, @@ -595,10 +611,10 @@ async def test_migrate_entity_ids( await instance.async_add_executor_job(_insert_states) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -636,22 +652,24 @@ async def test_post_migrate_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_2", last_updated_ts=2.252529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_1", last_updated_ts=3.152529, @@ -661,10 +679,10 @@ async def test_post_migrate_entity_ids( await instance.async_add_executor_job(_insert_events) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDPostMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -688,18 +706,20 @@ async def test_migrate_null_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_states(): with session_scope(hass=hass) as session: session.add( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), ) session.add_all( - States( + old_db_schema.States( entity_id=None, state="empty", last_updated_ts=time + 1.452529, @@ -707,7 +727,7 @@ async def test_migrate_null_entity_ids( for time in range(1000) ) session.add( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=2.452529, @@ -716,11 +736,10 @@ async def test_migrate_null_entity_ids( await instance.async_add_executor_job(_insert_states) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDMigrationTask()) - await async_recorder_block_till_done(hass) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -758,18 +777,20 @@ async def test_migrate_null_event_type_ids( """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1.452529, ), ) session.add_all( - Events( + old_db_schema.Events( event_type=None, origin_idx=0, time_fired_ts=time + 1.452529, @@ -777,7 +798,7 @@ async def test_migrate_null_event_type_ids( for time in range(1000) ) session.add( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=2.452529, @@ -786,12 +807,10 @@ async def test_migrate_null_event_type_ids( await instance.async_add_executor_job(_insert_events) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder - instance.queue_task(EventTypeIDMigrationTask()) - await async_recorder_block_till_done(hass) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: From 77001b26debeb80bd42e22e863be9ad4e982a03a Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 20 Sep 2023 11:17:32 -0400 Subject: [PATCH 660/984] Add second test device for Roborock (#100565) --- tests/components/roborock/conftest.py | 34 ++- tests/components/roborock/mock_data.py | 53 +++- .../roborock/snapshots/test_diagnostics.ambr | 265 +++++++++++++++++- .../components/roborock/test_binary_sensor.py | 2 +- tests/components/roborock/test_init.py | 2 +- tests/components/roborock/test_sensor.py | 2 +- 6 files changed, 329 insertions(+), 29 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index ef841769f8d..3435bd58cb3 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -24,9 +24,21 @@ def bypass_api_fixture() -> None: "homeassistant.components.roborock.RoborockMqttClient.async_connect" ), patch( "homeassistant.components.roborock.RoborockMqttClient._send_command" + ), patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + return_value=HOME_DATA, + ), patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + return_value=NETWORK_INFO, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", return_value=PROP, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ), patch( + "homeassistant.components.roborock.RoborockMqttClient._wait_response" + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" ), patch( "roborock.api.AttributeCache.async_value" ), patch( @@ -53,25 +65,11 @@ def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture async def setup_entry( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, ) -> MockConfigEntry: """Set up the Roborock platform.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", - return_value=HOME_DATA, - ), patch( - "homeassistant.components.roborock.RoborockMqttClient.get_networking", - return_value=NETWORK_INFO, - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", - return_value=PROP, - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" - ), patch( - "homeassistant.components.roborock.RoborockMqttClient._wait_response" - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" - ): - assert await async_setup_component(hass, DOMAIN, {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() return mock_roborock_entry diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 6a2e1f4b5f1..87ed02bc3ec 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -13,6 +13,9 @@ from roborock.containers import ( ) from roborock.roborock_typing import DeviceProp +from homeassistant.components.roborock import CONF_BASE_URL, CONF_USER_DATA +from homeassistant.const import CONF_USERNAME + # All data is based on a U.S. customer with a Roborock S7 MaxV Ultra USER_EMAIL = "user@domain.com" @@ -48,9 +51,9 @@ USER_DATA = UserData.from_dict( ) MOCK_CONFIG = { - "username": USER_EMAIL, - "user_data": USER_DATA.as_dict(), - "base_url": None, + CONF_USERNAME: USER_EMAIL, + CONF_USER_DATA: USER_DATA.as_dict(), + CONF_BASE_URL: None, } HOME_DATA_RAW = { @@ -61,7 +64,7 @@ HOME_DATA_RAW = { "geoName": None, "products": [ { - "id": "abc123", + "id": "s7_product", "name": "Roborock S7 MaxV", "code": "a27", "model": "roborock.vacuum.a27", @@ -227,7 +230,7 @@ HOME_DATA_RAW = { "runtimeEnv": None, "timeZoneId": "America/Los_Angeles", "iconUrl": "", - "productId": "abc123", + "productId": "s7_product", "lon": None, "lat": None, "share": False, @@ -255,7 +258,45 @@ HOME_DATA_RAW = { "120": 0, }, "silentOtaSwitch": True, - } + }, + { + "duid": "device_2", + "name": "Roborock S7 2", + "attribute": None, + "activeTime": 1672364449, + "localKey": "device_2", + "runtimeEnv": None, + "timeZoneId": "America/Los_Angeles", + "iconUrl": "", + "productId": "s7_product", + "lon": None, + "lat": None, + "share": False, + "shareTime": None, + "online": True, + "fv": "02.56.02", + "pv": "1.0", + "roomId": 2362003, + "tuyaUuid": None, + "tuyaMigrated": False, + "extra": '{"RRPhotoPrivacyVersion": "1"}', + "sn": "abc123", + "featureSet": "2234201184108543", + "newFeatureSet": "0000000000002041", + "deviceStatus": { + "121": 8, + "122": 100, + "123": 102, + "124": 203, + "125": 94, + "126": 90, + "127": 87, + "128": 0, + "133": 1, + "120": 0, + }, + "silentOtaSwitch": True, + }, ], "receivedDevices": [], "rooms": [ diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index a766a6c2703..d8e5f7d4cb2 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -57,7 +57,7 @@ 'name': 'Roborock S7 MaxV', 'newFeatureSet': '0000000000002041', 'online': True, - 'productId': 'abc123', + 'productId': 's7_product', 'pv': '1.0', 'roomId': 2362003, 'share': False, @@ -77,7 +77,268 @@ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', - 'id': 'abc123', + 'id': 's7_product', + 'model': 'roborock.vacuum.a27', + 'name': 'Roborock S7 MaxV', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': '101', + 'mode': 'rw', + 'name': 'rpc_request', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': '102', + 'mode': 'rw', + 'name': 'rpc_response', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': '120', + 'mode': 'ro', + 'name': '错误代码', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': '121', + 'mode': 'ro', + 'name': '设备状态', + 'type': 'ENUM', + }), + dict({ + 'code': 'battery', + 'id': '122', + 'mode': 'ro', + 'name': '设备电量', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': '123', + 'mode': 'rw', + 'name': '清扫模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': '124', + 'mode': 'rw', + 'name': '拖地模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'main_brush_life', + 'id': '125', + 'mode': 'rw', + 'name': '主刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'side_brush_life', + 'id': '126', + 'mode': 'rw', + 'name': '边刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'filter_life', + 'id': '127', + 'mode': 'rw', + 'name': '滤网寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'additional_props', + 'id': '128', + 'mode': 'ro', + 'name': '额外状态', + 'type': 'RAW', + }), + dict({ + 'code': 'task_complete', + 'id': '130', + 'mode': 'ro', + 'name': '完成事件', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_low_power', + 'id': '131', + 'mode': 'ro', + 'name': '电量不足任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_in_motion', + 'id': '132', + 'mode': 'ro', + 'name': '运动中任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'charge_status', + 'id': '133', + 'mode': 'ro', + 'name': '充电状态', + 'type': 'RAW', + }), + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + ]), + }), + 'props': dict({ + 'cleanSummary': dict({ + 'cleanArea': 1159182500, + 'cleanCount': 31, + 'cleanTime': 74382, + 'dustCollectionCount': 25, + 'records': list([ + 1672543330, + 1672458041, + ]), + 'squareMeterCleanArea': 1159.2, + }), + 'consumable': dict({ + 'cleaningBrushWorkTimes': 65, + 'dustCollectionWorkTimes': 25, + 'filterElementWorkTime': 0, + 'filterTimeLeft': 465618, + 'filterWorkTime': 74382, + 'mainBrushTimeLeft': 1005618, + 'mainBrushWorkTime': 74382, + 'sensorDirtyTime': 74382, + 'sensorTimeLeft': 33618, + 'sideBrushTimeLeft': 645618, + 'sideBrushWorkTime': 74382, + 'strainerWorkTimes': 65, + }), + 'lastCleanRecord': dict({ + 'area': 20965000, + 'avoidCount': 19, + 'begin': 1672543330, + 'beginDatetime': '2023-01-01T03:22:10+00:00', + 'cleanType': 3, + 'complete': 1, + 'duration': 1176, + 'dustCollectionStatus': 1, + 'end': 1672544638, + 'endDatetime': '2023-01-01T03:43:58+00:00', + 'error': 0, + 'finishReason': 56, + 'mapFlag': 0, + 'squareMeterArea': 21.0, + 'startType': 2, + 'washCount': 2, + }), + 'status': dict({ + 'adbumperStatus': list([ + 0, + 0, + 0, + ]), + 'autoDustCollection': 1, + 'avoidCount': 19, + 'backType': -1, + 'battery': 100, + 'cameraStatus': 3457, + 'chargeStatus': 1, + 'cleanArea': 20965000, + 'cleanTime': 1176, + 'collisionAvoidStatus': 1, + 'debugMode': 0, + 'dndEnabled': 0, + 'dockErrorStatus': 0, + 'dockType': 3, + 'dustCollectionStatus': 0, + 'errorCode': 0, + 'fanPower': 102, + 'homeSecEnablePassword': 0, + 'homeSecStatus': 0, + 'inCleaning': 0, + 'inFreshState': 1, + 'inReturning': 0, + 'isExploring': 0, + 'isLocating': 0, + 'labStatus': 1, + 'lockStatus': 0, + 'mapPresent': 1, + 'mapStatus': 3, + 'mopForbiddenEnable': 1, + 'mopMode': 300, + 'msgSeq': 458, + 'msgVer': 2, + 'squareMeterCleanArea': 21.0, + 'state': 8, + 'switchMapMode': 0, + 'unsaveMapFlag': 0, + 'unsaveMapReason': 0, + 'washPhase': 0, + 'washReady': 0, + 'waterBoxCarriageStatus': 1, + 'waterBoxMode': 203, + 'waterBoxStatus': 1, + 'waterShortageStatus': 0, + }), + }), + }), + }), + '**REDACTED-1**': dict({ + 'api': dict({ + }), + 'roborock_device_info': dict({ + 'device': dict({ + 'activeTime': 1672364449, + 'deviceStatus': dict({ + '120': 0, + '121': 8, + '122': 100, + '123': 102, + '124': 203, + '125': 94, + '126': 90, + '127': 87, + '128': 0, + '133': 1, + }), + 'duid': '**REDACTED**', + 'extra': '{"RRPhotoPrivacyVersion": "1"}', + 'featureSet': '2234201184108543', + 'fv': '02.56.02', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Roborock S7 2', + 'newFeatureSet': '0000000000002041', + 'online': True, + 'productId': 's7_product', + 'pv': '1.0', + 'roomId': 2362003, + 'share': False, + 'silentOtaSwitch': True, + 'sn': 'abc123', + 'timeZoneId': 'America/Los_Angeles', + 'tuyaMigrated': False, + }), + 'network_info': dict({ + 'bssid': '**REDACTED**', + 'ip': '123.232.12.1', + 'mac': '**REDACTED**', + 'rssi': 90, + 'ssid': 'wifi', + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'code': 'a27', + 'id': 's7_product', 'model': 'roborock.vacuum.a27', 'name': 'Roborock S7 MaxV', 'schema': list([ diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index d4d415424bc..310643355b0 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -9,7 +9,7 @@ async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 2 + assert len(hass.states.async_all("binary_sensor")) == 4 assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 05bf0848475..a5ad24b431c 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -21,7 +21,7 @@ async def test_unload_entry( ) as mock_disconnect: assert await hass.config_entries.async_unload(setup_entry.entry_id) await hass.async_block_till_done() - assert mock_disconnect.call_count == 1 + assert mock_disconnect.call_count == 2 assert setup_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index a022f0dfa51..0089c9a60bd 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 12 + assert len(hass.states.async_all("sensor")) == 24 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) From ec5675ff4b3ce995cdacca1bb4af1a4c34a3f03e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 17:37:13 +0200 Subject: [PATCH 661/984] Fix hkid matching in homekit_controller when zeroconf value is not upper case (#100641) --- .../homekit_controller/config_flow.py | 46 +++++------ .../homekit_controller/test_config_flow.py | 77 +++++++++++++++++++ 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 988adbd87a7..088747d39ff 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -80,12 +80,12 @@ def formatted_category(category: Categories) -> str: @callback -def find_existing_host( - hass: HomeAssistant, serial: str +def find_existing_config_entry( + hass: HomeAssistant, upper_case_hkid: str ) -> config_entries.ConfigEntry | None: """Return a set of the configured hosts.""" for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data.get("AccessoryPairingID") == serial: + if entry.data.get("AccessoryPairingID") == upper_case_hkid: return entry return None @@ -114,7 +114,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the homekit_controller flow.""" self.model: str | None = None - self.hkid: str | None = None + self.hkid: str | None = None # This is always lower case self.name: str | None = None self.category: Categories | None = None self.devices: dict[str, AbstractDiscovery] = {} @@ -199,11 +199,12 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self._async_step_pair_show_form() - async def _hkid_is_homekit(self, hkid: str) -> bool: + @callback + def _hkid_is_homekit(self, hkid: str) -> bool: """Determine if the device is a homekit bridge or accessory.""" dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, hkid)} + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(hkid))} ) if device is None: @@ -244,17 +245,10 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # The hkid is a unique random number that looks like a pairing code. # It changes if a device is factory reset. - hkid = properties[zeroconf.ATTR_PROPERTIES_ID] + hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID] normalized_hkid = normalize_hkid(hkid) - - # If this aiohomekit doesn't support this particular device, ignore it. - if not domain_supported(discovery_info.name): - return self.async_abort(reason="ignored_model") - - model = properties["md"] - name = domain_to_name(discovery_info.name) + upper_case_hkid = hkid.upper() status_flags = int(properties["sf"]) - category = Categories(int(properties.get("ci", 0))) paired = not status_flags & 0x01 # Set unique-id and error out if it's already configured @@ -265,23 +259,29 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "AccessoryIP": discovery_info.host, "AccessoryPort": discovery_info.port, } - # If the device is already paired and known to us we should monitor c# # (config_num) for changes. If it changes, we check for new entities - if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}): + if paired and upper_case_hkid in self.hass.data.get(KNOWN_DEVICES, {}): if existing_entry: self.hass.config_entries.async_update_entry( existing_entry, data={**existing_entry.data, **updated_ip_port} ) return self.async_abort(reason="already_configured") - _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) + # If this aiohomekit doesn't support this particular device, ignore it. + if not domain_supported(discovery_info.name): + return self.async_abort(reason="ignored_model") + + model = properties["md"] + name = domain_to_name(discovery_info.name) + _LOGGER.debug("Discovered device %s (%s - %s)", name, model, upper_case_hkid) # Device isn't paired with us or anyone else. # But we have a 'complete' config entry for it - that is probably # invalid. Remove it automatically. - existing = find_existing_host(self.hass, hkid) - if not paired and existing: + if not paired and ( + existing := find_existing_config_entry(self.hass, upper_case_hkid) + ): if self.controller is None: await self._async_setup_controller() @@ -348,13 +348,13 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # If this is a HomeKit bridge/accessory exported # by *this* HA instance ignore it. - if await self._hkid_is_homekit(hkid): + if self._hkid_is_homekit(hkid): return self.async_abort(reason="ignored_model") self.name = name self.model = model - self.category = category - self.hkid = hkid + self.category = Categories(int(properties.get("ci", 0))) + self.hkid = normalized_hkid # We want to show the pairing form - but don't call async_step_pair # directly as it has side effects (will ask the device to show a diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 469bd8618d2..3412e41aa17 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -1180,3 +1180,80 @@ async def test_bluetooth_valid_device_discovery_unpaired( assert result3["data"] == {} assert storage.get_map("00:00:00:00:00:00") is not None + + +async def test_discovery_updates_ip_when_config_entry_set_up( + hass: HomeAssistant, controller +) -> None: + """Already configured updates ip when config entry set up.""" + entry = MockConfigEntry( + domain="homekit_controller", + data={ + "AccessoryIP": "4.4.4.4", + "AccessoryPort": 66, + "AccessoryPairingID": "AA:BB:CC:DD:EE:FF", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + connection_mock = AsyncMock() + hass.data[KNOWN_DEVICES] = {"AA:BB:CC:DD:EE:FF": connection_mock} + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Set device as already paired + discovery_info.properties["sf"] = 0x00 + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert entry.data["AccessoryIP"] == discovery_info.host + assert entry.data["AccessoryPort"] == discovery_info.port + + +async def test_discovery_updates_ip_config_entry_not_set_up( + hass: HomeAssistant, controller +) -> None: + """Already configured updates ip when the config entry is not set up.""" + entry = MockConfigEntry( + domain="homekit_controller", + data={ + "AccessoryIP": "4.4.4.4", + "AccessoryPort": 66, + "AccessoryPairingID": "AA:BB:CC:DD:EE:FF", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + AsyncMock() + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Set device as already paired + discovery_info.properties["sf"] = 0x00 + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert entry.data["AccessoryIP"] == discovery_info.host + assert entry.data["AccessoryPort"] == discovery_info.port From fbcc5318c575d4cbabb1e96472636503e0fc8a75 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 20 Sep 2023 18:09:12 +0200 Subject: [PATCH 662/984] Move attributes to be excluded from recording to entity classes (#100239) Co-authored-by: J. Nick Koston --- .../components/automation/__init__.py | 3 ++ .../components/automation/recorder.py | 12 ----- .../components/recorder/db_schema.py | 2 + homeassistant/core.py | 5 ++ homeassistant/helpers/entity.py | 46 +++++++++++++++++-- 5 files changed, 52 insertions(+), 16 deletions(-) delete mode 100644 homeassistant/components/automation/recorder.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f4db7831235..fd6a70cce46 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -314,6 +314,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class BaseAutomationEntity(ToggleEntity, ABC): """Base class for automation entities.""" + _entity_component_unrecorded_attributes = frozenset( + (ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, CONF_ID) + ) raw_config: ConfigType | None @property diff --git a/homeassistant/components/automation/recorder.py b/homeassistant/components/automation/recorder.py deleted file mode 100644 index 3083d271d1f..00000000000 --- a/homeassistant/components/automation/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_CUR, ATTR_LAST_TRIGGERED, ATTR_MAX, ATTR_MODE, CONF_ID - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude extra attributes from being recorded in the database.""" - return {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, CONF_ID} diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index e25c6d6dd5f..e992a683cb1 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -576,6 +576,8 @@ class StateAttributes(Base): integration_attrs := exclude_attrs_by_domain.get(entity_info["domain"]) ): exclude_attrs |= integration_attrs + if state_info := state.state_info: + exclude_attrs |= state_info["unrecorded_attributes"] encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes bytes_result = encoder( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} diff --git a/homeassistant/core.py b/homeassistant/core.py index a43fa1997c6..a50d43c1344 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -95,6 +95,7 @@ if TYPE_CHECKING: from .auth import AuthManager from .components.http import ApiConfig, HomeAssistantHTTP from .config_entries import ConfigEntries + from .helpers.entity import StateInfo STAGE_1_SHUTDOWN_TIMEOUT = 100 @@ -1249,6 +1250,7 @@ class State: last_updated: datetime.datetime | None = None, context: Context | None = None, validate_entity_id: bool | None = True, + state_info: StateInfo | None = None, ) -> None: """Initialize a new state.""" state = str(state) @@ -1267,6 +1269,7 @@ class State: self.last_updated = last_updated or dt_util.utcnow() self.last_changed = last_changed or self.last_updated self.context = context or Context() + self.state_info = state_info self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None @@ -1637,6 +1640,7 @@ class StateMachine: attributes: Mapping[str, Any] | None = None, force_update: bool = False, context: Context | None = None, + state_info: StateInfo | None = None, ) -> None: """Set the state of an entity, add entity if it does not exist. @@ -1688,6 +1692,7 @@ class StateMachine: now, context, old_state is None, + state_info, ) if old_state is not None: old_state.expire() diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 5ed16408388..9b16b0c24fd 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -201,6 +201,12 @@ class EntityInfo(TypedDict): config_entry: NotRequired[str] +class StateInfo(TypedDict): + """State info.""" + + unrecorded_attributes: frozenset[str] + + class EntityPlatformState(Enum): """The platform state of an entity.""" @@ -297,6 +303,22 @@ class Entity(ABC): # If entity is added to an entity platform _platform_state = EntityPlatformState.NOT_ADDED + # Attributes to exclude from recording, only set by base components, e.g. light + _entity_component_unrecorded_attributes: frozenset[str] = frozenset() + # Additional integration specific attributes to exclude from recording, set by + # platforms, e.g. a derived class in hue.light + _unrecorded_attributes: frozenset[str] = frozenset() + # Union of _entity_component_unrecorded_attributes and _unrecorded_attributes, + # set automatically by __init_subclass__ + __combined_unrecorded_attributes: frozenset[str] = ( + _entity_component_unrecorded_attributes | _unrecorded_attributes + ) + + # StateInfo. Set by EntityPlatform by calling async_internal_added_to_hass + # While not purely typed, it makes typehinting more useful for us + # and removes the need for constant None checks or asserts. + _state_info: StateInfo = None # type: ignore[assignment] + # Entity Properties _attr_assumed_state: bool = False _attr_attribution: str | None = None @@ -321,6 +343,13 @@ class Entity(ABC): _attr_unique_id: str | None = None _attr_unit_of_measurement: str | None + def __init_subclass__(cls, **kwargs: Any) -> None: + """Initialize an Entity subclass.""" + super().__init_subclass__(**kwargs) + cls.__combined_unrecorded_attributes = ( + cls._entity_component_unrecorded_attributes | cls._unrecorded_attributes + ) + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -875,7 +904,12 @@ class Entity(ABC): try: hass.states.async_set( - entity_id, state, attr, self.force_update, self._context + entity_id, + state, + attr, + self.force_update, + self._context, + self._state_info, ) except InvalidStateError: _LOGGER.exception("Failed to set state, fall back to %s", STATE_UNKNOWN) @@ -1081,15 +1115,19 @@ class Entity(ABC): Not to be extended by integrations. """ - info: EntityInfo = { + entity_info: EntityInfo = { "domain": self.platform.platform_name, "custom_component": "custom_components" in type(self).__module__, } if self.platform.config_entry: - info["config_entry"] = self.platform.config_entry.entry_id + entity_info["config_entry"] = self.platform.config_entry.entry_id - entity_sources(self.hass)[self.entity_id] = info + entity_sources(self.hass)[self.entity_id] = entity_info + + self._state_info = { + "unrecorded_attributes": self.__combined_unrecorded_attributes + } if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests From 1f0c9a48d2860ce90b5b4ba28b84cb92a6c29af7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 18:35:55 +0200 Subject: [PATCH 663/984] Update doorbird zeroconf checks to use stdlib ipaddress methods (#100623) --- homeassistant/components/doorbird/config_flow.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 56a02f49042..983e56e64da 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from http import HTTPStatus -from ipaddress import ip_address import logging from typing import Any @@ -15,7 +14,6 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.util.network import is_ipv4_address, is_link_local from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI from .util import get_mac_address_from_door_station_info @@ -106,16 +104,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Prepare configuration for a discovered doorbird device.""" macaddress = discovery_info.properties["macaddress"] - host = discovery_info.host if macaddress[:6] != DOORBIRD_OUI: return self.async_abort(reason="not_doorbird_device") - if is_link_local(ip_address(host)): + if discovery_info.ip_address.is_link_local: return self.async_abort(reason="link_local_address") - if not is_ipv4_address(host): + if discovery_info.ip_address.version != 4: return self.async_abort(reason="not_ipv4_address") await self.async_set_unique_id(macaddress) + host = discovery_info.host self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) From a03ad87cfb39874eebd215f4df44ab4a61b0ac91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 18:43:15 +0200 Subject: [PATCH 664/984] Avoid ConfigEntry lookups in hass.config_entries.async_entries for domain index (#100598) --- homeassistant/config_entries.py | 17 ++++++++--------- tests/common.py | 12 +++++------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f4e61bfffbd..ed5ba79c1b4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1047,7 +1047,7 @@ class ConfigEntries: self.options = OptionsFlowManager(hass) self._hass_config = hass_config self._entries: dict[str, ConfigEntry] = {} - self._domain_index: dict[str, list[str]] = {} + self._domain_index: dict[str, list[ConfigEntry]] = {} self._store = storage.Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) @@ -1077,9 +1077,7 @@ class ConfigEntries: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries.values()) - return [ - self._entries[entry_id] for entry_id in self._domain_index.get(domain, []) - ] + return list(self._domain_index.get(domain, [])) async def async_add(self, entry: ConfigEntry) -> None: """Add and setup an entry.""" @@ -1088,7 +1086,7 @@ class ConfigEntries: f"An entry with the id {entry.entry_id} already exists." ) self._entries[entry.entry_id] = entry - self._domain_index.setdefault(entry.domain, []).append(entry.entry_id) + self._domain_index.setdefault(entry.domain, []).append(entry) self._async_dispatch(ConfigEntryChange.ADDED, entry) await self.async_setup(entry.entry_id) self._async_schedule_save() @@ -1106,7 +1104,7 @@ class ConfigEntries: await entry.async_remove(self.hass) del self._entries[entry.entry_id] - self._domain_index[entry.domain].remove(entry.entry_id) + self._domain_index[entry.domain].remove(entry) if not self._domain_index[entry.domain]: del self._domain_index[entry.domain] self._async_schedule_save() @@ -1173,7 +1171,7 @@ class ConfigEntries: return entries = {} - domain_index: dict[str, list[str]] = {} + domain_index: dict[str, list[ConfigEntry]] = {} for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") @@ -1188,7 +1186,7 @@ class ConfigEntries: domain = entry["domain"] entry_id = entry["entry_id"] - entries[entry_id] = ConfigEntry( + config_entry = ConfigEntry( version=entry["version"], domain=domain, entry_id=entry_id, @@ -1207,7 +1205,8 @@ class ConfigEntries: pref_disable_new_entities=pref_disable_new_entities, pref_disable_polling=entry.get("pref_disable_polling"), ) - domain_index.setdefault(domain, []).append(entry_id) + entries[entry_id] = config_entry + domain_index.setdefault(domain, []).append(config_entry) self._domain_index = domain_index self._entries = entries diff --git a/tests/common.py b/tests/common.py index 48bb38383c7..af18640843d 100644 --- a/tests/common.py +++ b/tests/common.py @@ -891,7 +891,7 @@ class MockConfigEntry(config_entries.ConfigEntry): unique_id=None, disabled_by=None, reason=None, - ): + ) -> None: """Initialize a mock config entry.""" kwargs = { "entry_id": entry_id or uuid_util.random_uuid_hex(), @@ -913,17 +913,15 @@ class MockConfigEntry(config_entries.ConfigEntry): if reason is not None: self.reason = reason - def add_to_hass(self, hass): + def add_to_hass(self, hass: HomeAssistant) -> None: """Test helper to add entry to hass.""" hass.config_entries._entries[self.entry_id] = self - hass.config_entries._domain_index.setdefault(self.domain, []).append( - self.entry_id - ) + hass.config_entries._domain_index.setdefault(self.domain, []).append(self) - def add_to_manager(self, manager): + def add_to_manager(self, manager: config_entries.ConfigEntries) -> None: """Test helper to add entry to entry manager.""" manager._entries[self.entry_id] = self - manager._domain_index.setdefault(self.domain, []).append(self.entry_id) + manager._domain_index.setdefault(self.domain, []).append(self) def patch_yaml_files(files_dict, endswith=True): From 6752af8f27516be52600c1782f160d724760e869 Mon Sep 17 00:00:00 2001 From: Andrei Demian Date: Wed, 20 Sep 2023 20:44:11 +0300 Subject: [PATCH 665/984] Bump ismartgate to 5.0.1 (#100636) --- homeassistant/components/gogogate2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index faebcf7e353..40633537ddf 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "local_polling", "loggers": ["ismartgate"], - "requirements": ["ismartgate==5.0.0"] + "requirements": ["ismartgate==5.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8eb3b324064..9d2d522595d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1076,7 +1076,7 @@ intellifire4py==2.2.2 iperf3==0.1.11 # homeassistant.components.gogogate2 -ismartgate==5.0.0 +ismartgate==5.0.1 # homeassistant.components.file_upload janus==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6d9bb9242e..738601f3ebf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -841,7 +841,7 @@ insteon-frontend-home-assistant==0.4.0 intellifire4py==2.2.2 # homeassistant.components.gogogate2 -ismartgate==5.0.0 +ismartgate==5.0.1 # homeassistant.components.file_upload janus==1.0.0 From 05254547380e95a902e41badc2c6159f250e11e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Sep 2023 20:47:38 +0200 Subject: [PATCH 666/984] Bump tibdex/github-app-token from 2.0.0 to 2.1.0 (#100632) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 212cd0498b6..c91117cb02d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: id: token # Pinned to a specific version of the action for security reasons # v1.7.0 - uses: tibdex/github-app-token@0914d50df753bbc42180d982a6550f195390069f + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a with: app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} From ed3cdca454c35ac85b8bcdb7d2ae744b0964808d Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 20 Sep 2023 16:02:00 -0400 Subject: [PATCH 667/984] Bump python-roborock to 0.34.1 (#100652) bump to 34.1 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 81bbd07d904..dfd5a9ee1c7 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.34.0"] + "requirements": ["python-roborock==0.34.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d2d522595d..609b55e42f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2166,7 +2166,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.34.0 +python-roborock==0.34.1 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 738601f3ebf..45159786747 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1598,7 +1598,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.34.0 +python-roborock==0.34.1 # homeassistant.components.smarttub python-smarttub==0.0.33 From 5c1a3998aeaa4b6c4cf524b11f561f6ce27f7674 Mon Sep 17 00:00:00 2001 From: anonion Date: Wed, 20 Sep 2023 21:59:05 -0700 Subject: [PATCH 668/984] Add Enmax virtual integration to Opower (#100503) * add enmax virtual integration supported by opower * update integrations.json --- homeassistant/components/enmax/__init__.py | 1 + homeassistant/components/enmax/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/enmax/__init__.py create mode 100644 homeassistant/components/enmax/manifest.json diff --git a/homeassistant/components/enmax/__init__.py b/homeassistant/components/enmax/__init__.py new file mode 100644 index 00000000000..21ca8ab1c58 --- /dev/null +++ b/homeassistant/components/enmax/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Enmax Energy.""" diff --git a/homeassistant/components/enmax/manifest.json b/homeassistant/components/enmax/manifest.json new file mode 100644 index 00000000000..2c2be413824 --- /dev/null +++ b/homeassistant/components/enmax/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "enmax", + "name": "Enmax Energy", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 966cf186346..8c7defb6969 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1481,6 +1481,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "enmax": { + "name": "Enmax Energy", + "integration_type": "virtual", + "supported_by": "opower" + }, "enocean": { "name": "EnOcean", "integration_type": "hub", From 9f56aec2676f24ba23487aa18b9c4966d98e9afe Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 21 Sep 2023 02:13:48 -0400 Subject: [PATCH 669/984] Bump zwave-js-server-python to 0.51.3 (#100665) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 4ea46099f14..cfb2c239d8e 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.51.2"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 609b55e42f5..e7cb02e348f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2806,7 +2806,7 @@ zigpy==0.57.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.2 +zwave-js-server-python==0.51.3 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45159786747..1fcd6625031 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2076,7 +2076,7 @@ zigpy-znp==0.11.4 zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.2 +zwave-js-server-python==0.51.3 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From f2fc62138aa3714ced71bd758ccc052fa02d100d Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Thu, 21 Sep 2023 08:40:07 +0200 Subject: [PATCH 670/984] Clean-up Minecraft Server constants (#100666) --- .../minecraft_server/binary_sensor.py | 6 +++- .../minecraft_server/config_flow.py | 4 ++- .../components/minecraft_server/const.py | 25 ------------- .../minecraft_server/coordinator.py | 5 +-- .../components/minecraft_server/entity.py | 4 ++- .../components/minecraft_server/helpers.py | 2 +- .../components/minecraft_server/sensor.py | 36 +++++++++---------- 7 files changed, 33 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 0446e0a2d7c..e89fce2d7d5 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -10,10 +10,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ICON_STATUS, KEY_STATUS +from .const import DOMAIN from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity +ICON_STATUS = "mdi:lan" + +KEY_STATUS = "status" + @dataclass class MinecraftServerBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index beacfde5b8e..f4b4212bc64 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -10,7 +10,9 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult from . import helpers -from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN + +DEFAULT_HOST = "localhost:25565" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index ea510c467a1..9f14f429a12 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,34 +1,9 @@ """Constants for the Minecraft Server integration.""" -ATTR_PLAYERS_LIST = "players_list" - -DEFAULT_HOST = "localhost:25565" DEFAULT_NAME = "Minecraft Server" DEFAULT_PORT = 25565 DOMAIN = "minecraft_server" -ICON_LATENCY = "mdi:signal" -ICON_PLAYERS_MAX = "mdi:account-multiple" -ICON_PLAYERS_ONLINE = "mdi:account-multiple" -ICON_PROTOCOL_VERSION = "mdi:numeric" -ICON_STATUS = "mdi:lan" -ICON_VERSION = "mdi:numeric" -ICON_MOTD = "mdi:minecraft" - KEY_LATENCY = "latency" -KEY_PLAYERS_MAX = "players_max" -KEY_PLAYERS_ONLINE = "players_online" -KEY_PROTOCOL_VERSION = "protocol_version" -KEY_STATUS = "status" -KEY_VERSION = "version" KEY_MOTD = "motd" - -MANUFACTURER = "Mojang AB" - -SCAN_INTERVAL = 60 - -SRV_RECORD_PREFIX = "_minecraft._tcp" - -UNIT_PLAYERS_MAX = "players" -UNIT_PLAYERS_ONLINE = "players" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 6965759e734..178c12772c6 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -14,7 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import helpers -from .const import SCAN_INTERVAL + +SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -45,7 +46,7 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): hass=hass, name=config_data[CONF_NAME], logger=_LOGGER, - update_interval=timedelta(seconds=SCAN_INTERVAL), + update_interval=SCAN_INTERVAL, ) # Server data diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index e7e91c7be86..9bac71e0000 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -3,9 +3,11 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN from .coordinator import MinecraftServerCoordinator +MANUFACTURER = "Mojang Studios" + class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): """Representation of a Minecraft Server base entity.""" diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py index ac9ec52f679..f5991620c68 100644 --- a/homeassistant/components/minecraft_server/helpers.py +++ b/homeassistant/components/minecraft_server/helpers.py @@ -6,7 +6,7 @@ import aiodns from homeassistant.const import CONF_HOST, CONF_PORT -from .const import SRV_RECORD_PREFIX +SRV_RECORD_PREFIX = "_minecraft._tcp" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 27749e5b60f..efe534e0f92 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -12,27 +12,27 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import ( - ATTR_PLAYERS_LIST, - DOMAIN, - ICON_LATENCY, - ICON_MOTD, - ICON_PLAYERS_MAX, - ICON_PLAYERS_ONLINE, - ICON_PROTOCOL_VERSION, - ICON_VERSION, - KEY_LATENCY, - KEY_MOTD, - KEY_PLAYERS_MAX, - KEY_PLAYERS_ONLINE, - KEY_PROTOCOL_VERSION, - KEY_VERSION, - UNIT_PLAYERS_MAX, - UNIT_PLAYERS_ONLINE, -) +from .const import DOMAIN, KEY_LATENCY, KEY_MOTD from .coordinator import MinecraftServerCoordinator, MinecraftServerData from .entity import MinecraftServerEntity +ATTR_PLAYERS_LIST = "players_list" + +ICON_LATENCY = "mdi:signal" +ICON_PLAYERS_MAX = "mdi:account-multiple" +ICON_PLAYERS_ONLINE = "mdi:account-multiple" +ICON_PROTOCOL_VERSION = "mdi:numeric" +ICON_VERSION = "mdi:numeric" +ICON_MOTD = "mdi:minecraft" + +KEY_PLAYERS_MAX = "players_max" +KEY_PLAYERS_ONLINE = "players_online" +KEY_PROTOCOL_VERSION = "protocol_version" +KEY_VERSION = "version" + +UNIT_PLAYERS_MAX = "players" +UNIT_PLAYERS_ONLINE = "players" + @dataclass class MinecraftServerEntityDescriptionMixin: From 59daceafd2c70256e01382542cd2bed70be469df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Sep 2023 09:48:41 +0200 Subject: [PATCH 671/984] Avoid calling extract_stack in system_log since it does blocking I/O (#100455) --- .../components/system_log/__init__.py | 75 +++++++++++++------ homeassistant/components/zha/core/gateway.py | 18 ++--- tests/components/system_log/test_init.py | 56 ++++++++++---- 3 files changed, 103 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index ab271ec676c..fab2b7ee291 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import OrderedDict, deque import logging import re +import sys import traceback from typing import Any, cast @@ -59,31 +60,65 @@ SERVICE_WRITE_SCHEMA = vol.Schema( def _figure_out_source( - record: logging.LogRecord, call_stack: list[tuple[str, int]], paths_re: re.Pattern + record: logging.LogRecord, paths_re: re.Pattern ) -> tuple[str, int]: + """Figure out where a log message came from.""" # If a stack trace exists, extract file names from the entire call stack. # The other case is when a regular "log" is made (without an attached # exception). In that case, just use the file where the log was made from. if record.exc_info: stack = [(x[0], x[1]) for x in traceback.extract_tb(record.exc_info[2])] - else: - index = -1 - for i, frame in enumerate(call_stack): - if frame[0] == record.pathname: - index = i + for i, (filename, _) in enumerate(stack): + # Slice the stack to the first frame that matches + # the record pathname. + if filename == record.pathname: + stack = stack[0 : i + 1] break - if index == -1: - # For some reason we couldn't find pathname in the stack. - stack = [(record.pathname, record.lineno)] - else: - stack = call_stack[0 : index + 1] + # Iterate through the stack call (in reverse) and find the last call from + # a file in Home Assistant. Try to figure out where error happened. + for path, line_number in reversed(stack): + # Try to match with a file within Home Assistant + if match := paths_re.match(path): + return (cast(str, match.group(1)), line_number) + else: + # + # We need to figure out where the log call came from if we + # don't have an exception. + # + # We do this by walking up the stack until we find the first + # frame match the record pathname so the code below + # can be used to reverse the remaining stack frames + # and find the first one that is from a file within Home Assistant. + # + # We do not call traceback.extract_stack() because it is + # it makes many stat() syscalls calls which do blocking I/O, + # and since this code is running in the event loop, we need to avoid + # blocking I/O. + + frame = sys._getframe(4) # pylint: disable=protected-access + # + # We use _getframe with 4 to skip the following frames: + # + # Jump 2 frames up to get to the actual caller + # since we are in a function, and always called from another function + # that are never the original source of the log message. + # + # Next try to skip any frames that are from the logging module + # We know that the logger module typically has 5 frames itself + # but it may change in the future so we are conservative and + # only skip 2. + # + # _getframe is cpython only but we are already using cpython specific + # code everywhere in HA so it's fine as its unlikely we will ever + # support other python implementations. + # + # Iterate through the stack call (in reverse) and find the last call from + # a file in Home Assistant. Try to figure out where error happened. + while back := frame.f_back: + if match := paths_re.match(frame.f_code.co_filename): + return (cast(str, match.group(1)), frame.f_lineno) + frame = back - # Iterate through the stack call (in reverse) and find the last call from - # a file in Home Assistant. Try to figure out where error happened. - for pathname in reversed(stack): - # Try to match with a file within Home Assistant - if match := paths_re.match(pathname[0]): - return (cast(str, match.group(1)), pathname[1]) # Ok, we don't know what this is return (record.pathname, record.lineno) @@ -217,11 +252,7 @@ class LogErrorHandler(logging.Handler): default upper limit is set to 50 (older entries are discarded) but can be changed if needed. """ - stack = [] - if not record.exc_info: - stack = [(f[0], f[1]) for f in traceback.extract_stack()] - - entry = LogEntry(record, _figure_out_source(record, stack, self.paths_re)) + entry = LogEntry(record, _figure_out_source(record, self.paths_re)) self.records.add_entry(entry) if self.fire_event: self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict()) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 5fe84005d7a..c5d04dda961 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -10,7 +10,6 @@ import itertools import logging import re import time -import traceback from typing import TYPE_CHECKING, Any, NamedTuple from zigpy.application import ControllerApplication @@ -814,21 +813,20 @@ class LogRelayHandler(logging.Handler): super().__init__() self.hass = hass self.gateway = gateway - - def emit(self, record: LogRecord) -> None: - """Relay log message via dispatcher.""" - stack = [] - if record.levelno >= logging.WARN and not record.exc_info: - stack = [f for f, _, _, _ in traceback.extract_stack()] - hass_path: str = HOMEASSISTANT_PATH[0] config_dir = self.hass.config.config_dir - paths_re = re.compile( + self.paths_re = re.compile( r"(?:{})/(.*)".format( "|".join([re.escape(x) for x in (hass_path, config_dir)]) ) ) - entry = LogEntry(record, _figure_out_source(record, stack, paths_re)) + + def emit(self, record: LogRecord) -> None: + """Relay log message via dispatcher.""" + if record.levelno >= logging.WARN: + entry = LogEntry(record, _figure_out_source(record, self.paths_re)) + else: + entry = LogEntry(record, (record.pathname, record.lineno)) async_dispatcher_send( self.hass, ZHA_GW_MSG, diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index bd861ac7668..1357d9e5e9e 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable import logging +import re import traceback from typing import Any from unittest.mock import MagicMock, patch @@ -87,11 +88,6 @@ class WatchLogErrorHandler(system_log.LogErrorHandler): self.watch_event.set() -def get_frame(name): - """Get log stack frame.""" - return (name, 5, None, None) - - async def async_setup_system_log(hass, config) -> WatchLogErrorHandler: """Set up the system_log component.""" WatchLogErrorHandler.instances = [] @@ -362,21 +358,28 @@ async def test_unknown_path( assert log["source"] == ["unknown_path", 0] +def get_frame(path: str, previous_frame: MagicMock | None) -> MagicMock: + """Get log stack frame.""" + return MagicMock( + f_back=previous_frame, + f_code=MagicMock(co_filename=path), + f_lineno=5, + ) + + async def async_log_error_from_test_path(hass, path, watcher): """Log error while mocking the path.""" call_path = "internal_path.py" + main_frame = get_frame("main_path/main.py", None) + path_frame = get_frame(path, main_frame) + call_path_frame = get_frame(call_path, path_frame) + logger_frame = get_frame("venv_path/logging/log.py", call_path_frame) + with patch.object( _LOGGER, "findCaller", MagicMock(return_value=(call_path, 0, None, None)) ), patch( - "traceback.extract_stack", - MagicMock( - return_value=[ - get_frame("main_path/main.py"), - get_frame(path), - get_frame(call_path), - get_frame("venv_path/logging/log.py"), - ] - ), + "homeassistant.components.system_log.sys._getframe", + return_value=logger_frame, ): wait_empty = watcher.add_watcher("error message") _LOGGER.error("error message") @@ -441,3 +444,28 @@ async def test_raise_during_log_capture( log = find_log(await get_error_log(hass_ws_client), "ERROR") assert log is not None assert_log(log, "", "Bad logger message: repr error", "ERROR") + + +async def test__figure_out_source(hass: HomeAssistant) -> None: + """Test that source is figured out correctly. + + We have to test this directly for exception tracebacks since + we cannot generate a trackback from a Home Assistant component + in a test because the test is not a component. + """ + try: + raise ValueError("test") + except ValueError as ex: + exc_info = (type(ex), ex, ex.__traceback__) + mock_record = MagicMock( + pathname="should not hit", + lineno=5, + exc_info=exc_info, + ) + regex_str = f"({__file__})" + file, line_no = system_log._figure_out_source( + mock_record, + re.compile(regex_str), + ) + assert file == __file__ + assert line_no != 5 From 715d8dcb9871e295543b47473855a9e5561aebc5 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 21 Sep 2023 10:44:32 +0200 Subject: [PATCH 672/984] Add test to london underground (#100562) Co-authored-by: Robert Resch --- CODEOWNERS | 1 + requirements_test_all.txt | 3 + .../components/london_underground/__init__.py | 1 + .../fixtures/line_status.json | 514 ++++++++++++++++++ .../london_underground/test_sensor.py | 36 ++ 5 files changed, 555 insertions(+) create mode 100644 tests/components/london_underground/__init__.py create mode 100644 tests/components/london_underground/fixtures/line_status.json create mode 100644 tests/components/london_underground/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fe6aba2e5bb..f3ff4024677 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -712,6 +712,7 @@ build.json @home-assistant/supervisor /homeassistant/components/logi_circle/ @evanjd /tests/components/logi_circle/ @evanjd /homeassistant/components/london_underground/ @jpbede +/tests/components/london_underground/ @jpbede /homeassistant/components/lookin/ @ANMalko @bdraco /tests/components/lookin/ @ANMalko @bdraco /homeassistant/components/loqed/ @mikewoudenberg diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fcd6625031..0f6f5d38806 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -897,6 +897,9 @@ life360==6.0.0 # homeassistant.components.logi_circle logi-circle==0.2.3 +# homeassistant.components.london_underground +london-tube-status==0.5 + # homeassistant.components.loqed loqedAPI==2.1.7 diff --git a/tests/components/london_underground/__init__.py b/tests/components/london_underground/__init__.py new file mode 100644 index 00000000000..5de380bde1c --- /dev/null +++ b/tests/components/london_underground/__init__.py @@ -0,0 +1 @@ +"""Tests for the london_underground component.""" diff --git a/tests/components/london_underground/fixtures/line_status.json b/tests/components/london_underground/fixtures/line_status.json new file mode 100644 index 00000000000..a014fc168c6 --- /dev/null +++ b/tests/components/london_underground/fixtures/line_status.json @@ -0,0 +1,514 @@ +[ + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "bakerloo", + "name": "Bakerloo", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Bakerloo&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "central", + "name": "Central", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Central&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Central&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "circle", + "name": "Circle", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Circle&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "district", + "name": "District", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "district", + "statusSeverity": 3, + "statusSeverityDescription": "Part Suspended", + "reason": "District Line: No service between Turnham Green and Ealing Broadway while we remove a tree from the track at Ealing Common. Valid tickets will be accepted on local buses. GOOD SERVICE on the rest of the line ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T18:25:36Z", + "toDate": "2023-09-18T22:06:14Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "District Line: No service between Turnham Green and Ealing Broadway while we remove a tree from the track at Ealing Common. Valid tickets will be accepted on local buses. GOOD SERVICE on the rest of the line ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "partSuspended" + } + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=District&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "dlr", + "name": "DLR", + "modeName": "dlr", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=DLR&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "elizabeth", + "name": "Elizabeth line", + "modeName": "elizabeth-line", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Elizabeth line&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "hammersmith-city", + "name": "Hammersmith & City", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Hammersmith & City&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "jubilee", + "name": "Jubilee", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Jubilee&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Jubilee&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "london-overground", + "name": "London Overground", + "modeName": "overground", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=London Overground&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=London Overground&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "metropolitan", + "name": "Metropolitan", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Metropolitan&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "northern", + "name": "Northern", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Northern&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Northern&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "piccadilly", + "name": "Piccadilly", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "piccadilly", + "statusSeverity": 6, + "statusSeverityDescription": "Severe Delays", + "reason": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T19:01:20Z", + "toDate": "2023-09-19T00:29:00Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "severeDelays" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "piccadilly", + "statusSeverity": 3, + "statusSeverityDescription": "Part Suspended", + "reason": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T19:01:20Z", + "toDate": "2023-09-18T22:06:14Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "partSuspended" + } + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Piccadilly&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Piccadilly&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "victoria", + "name": "Victoria", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Victoria&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Victoria&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "waterloo-city", + "name": "Waterloo & City", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Waterloo & City&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + } +] diff --git a/tests/components/london_underground/test_sensor.py b/tests/components/london_underground/test_sensor.py new file mode 100644 index 00000000000..4dda341279d --- /dev/null +++ b/tests/components/london_underground/test_sensor.py @@ -0,0 +1,36 @@ +"""The tests for the london_underground platform.""" +from london_tube_status import API_URL + +from homeassistant.components.london_underground.const import CONF_LINE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +VALID_CONFIG = { + "sensor": {"platform": "london_underground", CONF_LINE: ["Metropolitan"]} +} + + +async def test_valid_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test for operational london_underground sensor with proper attributes.""" + aioclient_mock.get( + API_URL, + text=load_fixture("line_status.json", "london_underground"), + ) + + assert await async_setup_component(hass, "sensor", VALID_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("sensor.metropolitan") + assert state + assert state.state == "Good Service" + assert state.attributes == { + "Description": "Nothing to report", + "attribution": "Powered by TfL Open Data", + "friendly_name": "Metropolitan", + "icon": "mdi:subway", + } From 15caf2ac03a126a4f00fe97d44aa3ce997708176 Mon Sep 17 00:00:00 2001 From: Mike <7278201+mike391@users.noreply.github.com> Date: Thu, 21 Sep 2023 04:53:18 -0400 Subject: [PATCH 673/984] Add support for Levoit Vital200s purifier (#100613) --- homeassistant/components/vesync/const.py | 7 ++++++- homeassistant/components/vesync/fan.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index b20a04b8a1c..f87f1cf3a8a 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -35,5 +35,10 @@ SKU_TO_BASE_DEVICE = { "Core600S": "Core600S", "LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S "LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S - "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S + "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S, + "LAP-V201S-AASR": "Vital200S", + "LAP-V201S-WJP": "Vital200S", + "LAP-V201S-WEU": "Vital200S", + "LAP-V201S-WUS": "Vital200S", + "LAP-V201-AUSR": "Vital200S", } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index e5347b204e6..87934ced81f 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -27,10 +27,12 @@ DEV_TYPE_TO_HA = { "Core300S": "fan", "Core400S": "fan", "Core600S": "fan", + "Vital200S": "fan", } FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" +FAN_MODE_PET = "pet" PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], @@ -38,6 +40,7 @@ PRESET_MODES = { "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], + "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -45,6 +48,7 @@ SPEED_RANGE = { # off is not included "Core300S": (1, 3), "Core400S": (1, 4), "Core600S": (1, 4), + "Vital200S": (1, 4), } From e4742c04f201c8520acfc3888f570e9ae171a314 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 21 Sep 2023 10:57:23 +0200 Subject: [PATCH 674/984] Fix missspelled package names (#100670) --- .../components/aquostv/manifest.json | 2 +- .../components/asterisk_mbox/manifest.json | 2 +- .../components/duotecno/manifest.json | 2 +- .../components/emulated_hue/manifest.json | 2 +- .../components/esphome/manifest.json | 2 +- homeassistant/components/foobot/manifest.json | 2 +- .../gardena_bluetooth/manifest.json | 2 +- .../components/greeneye_monitor/manifest.json | 2 +- homeassistant/components/http/manifest.json | 2 +- .../components/pushover/manifest.json | 2 +- .../components/tplink_omada/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 28 +++++++++---------- requirements_test.txt | 2 +- requirements_test_all.txt | 24 ++++++++-------- 15 files changed, 39 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json index 011b8e67a19..1bac2bdfb5f 100644 --- a/homeassistant/components/aquostv/manifest.json +++ b/homeassistant/components/aquostv/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aquostv", "iot_class": "local_polling", "loggers": ["sharp_aquos_rc"], - "requirements": ["sharp-aquos-rc==0.3.2"] + "requirements": ["sharp_aquos_rc==0.3.2"] } diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json index 840c48aff2a..8348e40ba6b 100644 --- a/homeassistant/components/asterisk_mbox/manifest.json +++ b/homeassistant/components/asterisk_mbox/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/asterisk_mbox", "iot_class": "local_push", "loggers": ["asterisk_mbox"], - "requirements": ["asterisk-mbox==0.5.0"] + "requirements": ["asterisk_mbox==0.5.0"] } diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index d26d4fce61e..be2a74f884f 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", - "requirements": ["pyduotecno==2023.8.4"] + "requirements": ["pyDuotecno==2023.8.4"] } diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index 01dae2dca77..ff3591e0066 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp-cors==0.7.0"] + "requirements": ["aiohttp_cors==0.7.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e311a0913ae..65c5bf44d5b 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": [ - "async_interrupt==1.1.1", + "async-interrupt==1.1.1", "aioesphomeapi==16.0.5", "bluetooth-data-tools==1.11.0", "esphome-dashboard-api==1.2.3" diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json index 890cd95784c..a517f1fea6f 100644 --- a/homeassistant/components/foobot/manifest.json +++ b/homeassistant/components/foobot/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/foobot", "iot_class": "cloud_polling", "loggers": ["foobot_async"], - "requirements": ["foobot-async==1.0.0"] + "requirements": ["foobot_async==1.0.0"] } diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 3e07eb1ad42..bcbb25d55a2 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena_bluetooth==1.4.0"] + "requirements": ["gardena-bluetooth==1.4.0"] } diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json index 33a4947c01d..fcf4d004d26 100644 --- a/homeassistant/components/greeneye_monitor/manifest.json +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", "iot_class": "local_push", "loggers": ["greeneye"], - "requirements": ["greeneye-monitor==3.0.3"] + "requirements": ["greeneye_monitor==3.0.3"] } diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index dec1b9485b6..bce425adbdb 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp-cors==0.7.0"] + "requirements": ["aiohttp_cors==0.7.0"] } diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json index 3b538f756e0..d086321c088 100644 --- a/homeassistant/components/pushover/manifest.json +++ b/homeassistant/components/pushover/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pushover", "iot_class": "cloud_push", "loggers": ["pushover_complete"], - "requirements": ["pushover-complete==1.1.1"] + "requirements": ["pushover_complete==1.1.1"] } diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 9c303b24661..3215a9ba77d 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink_omada_client==1.3.2"] + "requirements": ["tplink-omada-client==1.3.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b1ad7f7a3c5..6c65a08a97e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ aiodiscover==1.5.1 -aiohttp-cors==0.7.0 aiohttp==3.8.5 +aiohttp_cors==0.7.0 astral==2.2 async-timeout==4.0.3 async-upnp-client==0.35.1 diff --git a/requirements_all.txt b/requirements_all.txt index e7cb02e348f..c059d20cbd5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -254,7 +254,7 @@ aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp-cors==0.7.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==4.6.2 @@ -448,7 +448,10 @@ arris-tg2492lg==1.2.1 asmog==0.0.6 # homeassistant.components.asterisk_mbox -asterisk-mbox==0.5.0 +asterisk_mbox==0.5.0 + +# homeassistant.components.esphome +async-interrupt==1.1.1 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -458,9 +461,6 @@ asterisk-mbox==0.5.0 # homeassistant.components.yeelight async-upnp-client==0.35.1 -# homeassistant.components.esphome -async_interrupt==1.1.1 - # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -818,7 +818,7 @@ flux-led==1.0.4 fnv-hash-fast==0.4.1 # homeassistant.components.foobot -foobot-async==1.0.0 +foobot_async==1.0.0 # homeassistant.components.forecast_solar forecast-solar==3.0.0 @@ -840,7 +840,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.4.0 +gardena-bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -922,7 +922,7 @@ gps3==0.33.3 greeclimate==1.4.1 # homeassistant.components.greeneye_monitor -greeneye-monitor==3.0.3 +greeneye_monitor==3.0.3 # homeassistant.components.greenwave greenwavereality==0.5.1 @@ -1488,7 +1488,7 @@ pure-python-adb[async]==0.3.0.dev0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover-complete==1.1.1 +pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==1.0.0 @@ -1535,6 +1535,9 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 +# homeassistant.components.duotecno +pyDuotecno==2023.8.4 + # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1662,9 +1665,6 @@ pydrawise==2023.8.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 -# homeassistant.components.duotecno -pyduotecno==2023.8.4 - # homeassistant.components.ebox pyebox==1.1.4 @@ -2398,7 +2398,7 @@ sfrbox-api==0.0.6 sharkiq==1.0.2 # homeassistant.components.aquostv -sharp-aquos-rc==0.3.2 +sharp_aquos_rc==0.3.2 # homeassistant.components.shodan shodan==1.28.0 @@ -2587,7 +2587,7 @@ total-connect-client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink_omada_client==1.3.2 +tplink-omada-client==1.3.2 # homeassistant.components.transmission transmission-rpc==4.1.5 diff --git a/requirements_test.txt b/requirements_test.txt index 8da4e92c81d..2d0c256ac26 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==3.3.1 pytest==7.3.1 -requests_mock==1.11.0 +requests-mock==1.11.0 respx==0.20.2 syrupy==4.5.0 tqdm==4.66.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f6f5d38806..dbd433aa4c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp-cors==0.7.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==4.6.2 @@ -404,6 +404,9 @@ aranet4==2.1.3 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 +# homeassistant.components.esphome +async-interrupt==1.1.1 + # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv @@ -412,9 +415,6 @@ arcam-fmj==1.4.0 # homeassistant.components.yeelight async-upnp-client==0.35.1 -# homeassistant.components.esphome -async_interrupt==1.1.1 - # homeassistant.components.sleepiq asyncsleepiq==1.3.7 @@ -646,7 +646,7 @@ flux-led==1.0.4 fnv-hash-fast==0.4.1 # homeassistant.components.foobot -foobot-async==1.0.0 +foobot_async==1.0.0 # homeassistant.components.forecast_solar forecast-solar==3.0.0 @@ -662,7 +662,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.4.0 +gardena-bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -726,7 +726,7 @@ govee-ble==0.23.0 greeclimate==1.4.1 # homeassistant.components.greeneye_monitor -greeneye-monitor==3.0.3 +greeneye_monitor==3.0.3 # homeassistant.components.pure_energie gridnet==4.2.0 @@ -1130,7 +1130,7 @@ pure-python-adb[async]==0.3.0.dev0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover-complete==1.1.1 +pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==1.0.0 @@ -1165,6 +1165,9 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 +# homeassistant.components.duotecno +pyDuotecno==2023.8.4 + # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1241,9 +1244,6 @@ pydiscovergy==2.0.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 -# homeassistant.components.duotecno -pyduotecno==2023.8.4 - # homeassistant.components.econet pyeconet==0.1.20 @@ -1899,7 +1899,7 @@ toonapi==0.2.1 total-connect-client==2023.2 # homeassistant.components.tplink_omada -tplink_omada_client==1.3.2 +tplink-omada-client==1.3.2 # homeassistant.components.transmission transmission-rpc==4.1.5 From 11c4c37cf9f85f8e6c5a6c97eb830b9e90dcf5da Mon Sep 17 00:00:00 2001 From: Fletcher Date: Thu, 21 Sep 2023 17:06:55 +0800 Subject: [PATCH 675/984] Add Slack thread/reply support (#93384) --- CODEOWNERS | 4 +-- homeassistant/components/slack/const.py | 1 + homeassistant/components/slack/manifest.json | 2 +- homeassistant/components/slack/notify.py | 29 ++++++++++++++++++-- tests/components/slack/test_notify.py | 16 +++++++++++ 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f3ff4024677..5bd97369ef5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1145,8 +1145,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob -/homeassistant/components/slack/ @tkdrob -/tests/components/slack/ @tkdrob +/homeassistant/components/slack/ @tkdrob @fletcherau +/tests/components/slack/ @tkdrob @fletcherau /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 diff --git a/homeassistant/components/slack/const.py b/homeassistant/components/slack/const.py index ec0993e290b..ccc1fbb6643 100644 --- a/homeassistant/components/slack/const.py +++ b/homeassistant/components/slack/const.py @@ -10,6 +10,7 @@ ATTR_SNOOZE = "snooze_endtime" ATTR_URL = "url" ATTR_USERNAME = "username" ATTR_USER_ID = "user_id" +ATTR_THREAD_TS = "thread_ts" CONF_DEFAULT_CHANNEL = "default_channel" diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 2bd3476cbbe..1b35db6f061 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -1,7 +1,7 @@ { "domain": "slack", "name": "Slack", - "codeowners": ["@tkdrob"], + "codeowners": ["@tkdrob", "@fletcherau"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/slack", "integration_type": "service", diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 498eddffa3d..deba0796750 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -30,6 +30,7 @@ from .const import ( ATTR_FILE, ATTR_PASSWORD, ATTR_PATH, + ATTR_THREAD_TS, ATTR_URL, ATTR_USERNAME, CONF_DEFAULT_CHANNEL, @@ -50,7 +51,10 @@ FILE_URL_SCHEMA = vol.Schema( ) DATA_FILE_SCHEMA = vol.Schema( - {vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA)} + { + vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA), + vol.Optional(ATTR_THREAD_TS): cv.string, + } ) DATA_TEXT_ONLY_SCHEMA = vol.Schema( @@ -59,6 +63,7 @@ DATA_TEXT_ONLY_SCHEMA = vol.Schema( vol.Optional(ATTR_ICON): cv.string, vol.Optional(ATTR_BLOCKS): list, vol.Optional(ATTR_BLOCKS_TEMPLATE): list, + vol.Optional(ATTR_THREAD_TS): cv.string, } ) @@ -73,7 +78,7 @@ class AuthDictT(TypedDict, total=False): auth: BasicAuth -class FormDataT(TypedDict): +class FormDataT(TypedDict, total=False): """Type for form data, file upload.""" channels: str @@ -81,6 +86,7 @@ class FormDataT(TypedDict): initial_comment: str title: str token: str + thread_ts: str # Optional key class MessageT(TypedDict, total=False): @@ -92,6 +98,7 @@ class MessageT(TypedDict, total=False): icon_url: str # Optional key icon_emoji: str # Optional key blocks: list[Any] # Optional key + thread_ts: str # Optional key async def async_get_service( @@ -142,6 +149,7 @@ class SlackNotificationService(BaseNotificationService): targets: list[str], message: str, title: str | None, + thread_ts: str | None, ) -> None: """Upload a local file (with message) to Slack.""" if not self._hass.config.is_allowed_path(path): @@ -158,6 +166,7 @@ class SlackNotificationService(BaseNotificationService): filename=filename, initial_comment=message, title=title or filename, + thread_ts=thread_ts, ) except (SlackApiError, ClientError) as err: _LOGGER.error("Error while uploading file-based message: %r", err) @@ -168,6 +177,7 @@ class SlackNotificationService(BaseNotificationService): targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, password: str | None = None, @@ -205,6 +215,9 @@ class SlackNotificationService(BaseNotificationService): "token": self._client.token, } + if thread_ts: + form_data["thread_ts"] = thread_ts + data = FormData(form_data, charset="utf-8") data.add_field("file", resp.content, filename=filename) @@ -218,6 +231,7 @@ class SlackNotificationService(BaseNotificationService): targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, icon: str | None = None, @@ -238,6 +252,9 @@ class SlackNotificationService(BaseNotificationService): if blocks: message_dict["blocks"] = blocks + if thread_ts: + message_dict["thread_ts"] = thread_ts + tasks = { target: self._client.chat_postMessage(**message_dict, channel=target) for target in targets @@ -286,6 +303,7 @@ class SlackNotificationService(BaseNotificationService): title, username=data.get(ATTR_USERNAME, self._config.get(ATTR_USERNAME)), icon=data.get(ATTR_ICON, self._config.get(ATTR_ICON)), + thread_ts=data.get(ATTR_THREAD_TS), blocks=blocks, ) @@ -296,11 +314,16 @@ class SlackNotificationService(BaseNotificationService): targets, message, title, + thread_ts=data.get(ATTR_THREAD_TS), username=data[ATTR_FILE].get(ATTR_USERNAME), password=data[ATTR_FILE].get(ATTR_PASSWORD), ) # Message Type 3: A message that uploads a local file return await self._async_send_local_file_message( - data[ATTR_FILE][ATTR_PATH], targets, message, title + data[ATTR_FILE][ATTR_PATH], + targets, + message, + title, + thread_ts=data.get(ATTR_THREAD_TS), ) diff --git a/tests/components/slack/test_notify.py b/tests/components/slack/test_notify.py index 232f78e97e4..6c90ad8cd39 100644 --- a/tests/components/slack/test_notify.py +++ b/tests/components/slack/test_notify.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock from homeassistant.components import notify from homeassistant.components.slack import DOMAIN from homeassistant.components.slack.notify import ( + ATTR_THREAD_TS, CONF_DEFAULT_CHANNEL, SlackNotificationService, ) @@ -93,3 +94,18 @@ async def test_message_icon_url_overrides_default() -> None: mock_fn.assert_called_once() _, kwargs = mock_fn.call_args assert kwargs["icon_url"] == expected_icon + + +async def test_message_as_reply() -> None: + """Tests that a message pointer will be passed to Slack if specified.""" + mock_client = Mock() + mock_client.chat_postMessage = AsyncMock() + service = SlackNotificationService(None, mock_client, CONF_DATA) + + expected_ts = "1624146685.064129" + await service.async_send_message("test", data={ATTR_THREAD_TS: expected_ts}) + + mock_fn = mock_client.chat_postMessage + mock_fn.assert_called_once() + _, kwargs = mock_fn.call_args + assert kwargs["thread_ts"] == expected_ts From aed3ba3acd89099c28e00478c57fa80657da8920 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 21 Sep 2023 13:33:26 +0200 Subject: [PATCH 676/984] Avoid redundant calls to `async_ha_write_state` in MQTT (binary) sensor (#100438) * Only call `async_ha_write_state` on changes. * Make helper class * Use UndefinedType * Remove del * Integrate monitor into MqttEntity * Track extra state attributes and availability * Add `__slots__` * Add monitor to MqttAttributes and MqttAvailability * Write out loop * Add test * Make common test and parameterize * Add test for last_reset attribute * MqttMonitorEntity base class * Rename attr and update docstr `track` method. * correction doct * Implement as a decorator * Move tracking functions into decorator * Rename decorator * Follow up comment --- .../components/mqtt/binary_sensor.py | 5 +-- homeassistant/components/mqtt/mixins.py | 45 ++++++++++++++++--- homeassistant/components/mqtt/sensor.py | 4 +- tests/components/mqtt/test_binary_sensor.py | 36 +++++++++++++++ tests/components/mqtt/test_common.py | 25 +++++++++++ tests/components/mqtt/test_sensor.py | 43 ++++++++++++++++++ 6 files changed, 147 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index a1341350a7a..505305cad3e 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -43,9 +43,9 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -191,6 +191,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT state message.""" # auto-expire enabled? @@ -257,8 +258,6 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): self.hass, off_delay, off_delay_listener ) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 795eb30e8e2..a01691f0601 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio from collections.abc import Callable, Coroutine -from functools import partial +from functools import partial, wraps import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final @@ -101,6 +101,7 @@ from .discovery import ( set_discovery_hash, ) from .models import ( + MessageCallbackType, MqttValueTemplate, PublishPayloadType, ReceiveMessage, @@ -346,6 +347,41 @@ def init_entity_id_from_config( ) +def write_state_on_attr_change( + entity: Entity, attributes: set[str] +) -> Callable[[MessageCallbackType], MessageCallbackType]: + """Wrap an MQTT message callback to track state attribute changes.""" + + def _attrs_have_changed(tracked_attrs: dict[str, Any]) -> bool: + """Return True if attributes on entity changed or if update is forced.""" + if not (write_state := (getattr(entity, "_attr_force_update", False))): + for attribute, last_value in tracked_attrs.items(): + if getattr(entity, attribute, UNDEFINED) != last_value: + write_state = True + break + + return write_state + + def _decorator(msg_callback: MessageCallbackType) -> MessageCallbackType: + @wraps(msg_callback) + def wrapper(msg: ReceiveMessage) -> None: + """Track attributes for write state requests.""" + tracked_attrs: dict[str, Any] = { + attribute: getattr(entity, attribute, UNDEFINED) + for attribute in attributes + } + msg_callback(msg) + if not _attrs_have_changed(tracked_attrs): + return + + mqtt_data = get_mqtt_data(entity.hass) + mqtt_data.state_write_requests.write_state_request(entity) + + return wrapper + + return _decorator + + class MqttAttributes(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -379,6 +415,7 @@ class MqttAttributes(Entity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_extra_state_attributes"}) def attributes_message_received(msg: ReceiveMessage) -> None: try: payload = attr_tpl(msg.payload) @@ -391,9 +428,6 @@ class MqttAttributes(Entity): and k not in self._attributes_extra_blocked } self._attr_extra_state_attributes = filtered_dict - get_mqtt_data(self.hass).state_write_requests.write_state_request( - self - ) else: _LOGGER.warning("JSON result was not a dictionary") except ValueError: @@ -488,6 +522,7 @@ class MqttAvailability(Entity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"available"}) def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" topic = msg.topic @@ -500,8 +535,6 @@ class MqttAvailability(Entity): self._available[topic] = False self._available_latest = False - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self._available = { topic: (self._available[topic] if topic in self._available else False) for topic in self._avail_topics diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 70c8d505b4f..278e70a9737 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -45,6 +45,7 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import ( MqttValueTemplate, @@ -52,7 +53,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -287,13 +287,13 @@ class MqttSensor(MqttEntity, RestoreSensor): ) @callback + @write_state_on_attr_change(self, {"_attr_native_value", "_attr_last_reset"}) @log_messages(self.hass, self.entity_id) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" _update_state(msg) if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: _update_last_reset(msg) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 91a4833b1fc..ea9c8072290 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -47,6 +47,7 @@ from .test_common import ( help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1248,3 +1249,38 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "ON", "OFF"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 9aa88c2d7ba..64bece5369e 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1925,3 +1925,28 @@ async def help_test_discovery_setup( await hass.async_block_till_done() state = hass.states.get(f"{domain}.{name}") assert state and state.state is not None + + +async def help_test_skipped_async_ha_write_state( + hass: HomeAssistant, topic: str, payload1: str, payload2: str +) -> None: + """Test entity.async_ha_write_state is only called on changes.""" + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + assert len(mock_async_ha_write_state.mock_calls) == 0 + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index d9c92b315b3..bc75492a03e 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -60,6 +60,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1437,3 +1438,45 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "value_template": "{{ value_json.state }}", + "last_reset_value_template": "{{ value_json.last_reset }}", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"val1"}', '{"state":"val2"}'), + ( + "test-topic", + '{"last_reset":"2023-09-15 15:11:03"}', + '{"last_reset":"2023-09-16 15:11:02"}', + ), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From df73850f5679e260da29028a74676395ec5a9bfb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 21 Sep 2023 15:02:47 +0200 Subject: [PATCH 677/984] Move definition of attributes excluded from history to entity classes (#100430) * Move definition of attributes excluded from history to entity classes * Revert change which should be in a follow-up PR * Fix sun unrecorded attributes * Fix input_select unrecorded attributes --- .../components/automation/__init__.py | 7 ---- homeassistant/components/calendar/__init__.py | 2 ++ homeassistant/components/calendar/recorder.py | 10 ------ homeassistant/components/camera/__init__.py | 4 +++ homeassistant/components/camera/recorder.py | 10 ------ homeassistant/components/climate/__init__.py | 14 ++++++++ homeassistant/components/climate/recorder.py | 32 ------------------- homeassistant/components/event/__init__.py | 2 ++ homeassistant/components/event/recorder.py | 12 ------- homeassistant/components/fan/__init__.py | 2 ++ homeassistant/components/fan/recorder.py | 12 ------- homeassistant/components/group/__init__.py | 7 ++-- .../components/group/media_player.py | 2 ++ homeassistant/components/group/recorder.py | 16 ---------- .../components/humidifier/__init__.py | 4 +++ .../components/humidifier/recorder.py | 16 ---------- homeassistant/components/image/__init__.py | 4 +++ homeassistant/components/image/recorder.py | 10 ------ .../components/input_boolean/__init__.py | 9 ++---- .../components/input_boolean/recorder.py | 11 ------- .../components/input_button/__init__.py | 9 ++---- .../components/input_button/recorder.py | 11 ------- .../components/input_datetime/__init__.py | 9 ++---- .../components/input_datetime/recorder.py | 13 -------- .../components/input_number/__init__.py | 11 +++---- .../components/input_number/recorder.py | 19 ----------- .../components/input_select/__init__.py | 12 +++---- .../components/input_select/recorder.py | 11 ------- .../components/input_text/__init__.py | 11 +++---- .../components/input_text/recorder.py | 19 ----------- homeassistant/components/light/__init__.py | 11 +++++++ homeassistant/components/light/recorder.py | 26 --------------- .../components/media_player/__init__.py | 12 +++++++ .../components/media_player/recorder.py | 26 --------------- homeassistant/components/number/__init__.py | 4 +++ homeassistant/components/number/recorder.py | 17 ---------- homeassistant/components/person/__init__.py | 8 ++--- homeassistant/components/person/recorder.py | 12 ------- homeassistant/components/schedule/__init__.py | 11 +++---- homeassistant/components/schedule/recorder.py | 16 ---------- homeassistant/components/script/__init__.py | 11 +++---- homeassistant/components/script/recorder.py | 12 ------- homeassistant/components/select/__init__.py | 2 ++ homeassistant/components/select/recorder.py | 12 ------- homeassistant/components/sensor/__init__.py | 2 ++ homeassistant/components/sensor/recorder.py | 16 ++-------- homeassistant/components/siren/__init__.py | 2 ++ homeassistant/components/siren/recorder.py | 12 ------- homeassistant/components/sun/__init__.py | 26 +++++++++++---- homeassistant/components/sun/recorder.py | 32 ------------------- homeassistant/components/text/__init__.py | 4 +++ homeassistant/components/text/recorder.py | 12 ------- .../trafikverket_camera/__init__.py | 12 ------- .../components/trafikverket_camera/camera.py | 2 ++ .../trafikverket_camera/recorder.py | 13 -------- .../components/unifiprotect/entity.py | 2 ++ .../components/unifiprotect/recorder.py | 12 ------- homeassistant/components/update/__init__.py | 6 +++- homeassistant/components/update/recorder.py | 13 -------- homeassistant/components/vacuum/__init__.py | 2 ++ homeassistant/components/vacuum/recorder.py | 12 ------- .../components/water_heater/__init__.py | 4 +++ .../components/water_heater/recorder.py | 12 ------- homeassistant/components/weather/__init__.py | 2 ++ homeassistant/components/weather/recorder.py | 12 ------- 65 files changed, 143 insertions(+), 558 deletions(-) delete mode 100644 homeassistant/components/calendar/recorder.py delete mode 100644 homeassistant/components/camera/recorder.py delete mode 100644 homeassistant/components/climate/recorder.py delete mode 100644 homeassistant/components/event/recorder.py delete mode 100644 homeassistant/components/fan/recorder.py delete mode 100644 homeassistant/components/group/recorder.py delete mode 100644 homeassistant/components/humidifier/recorder.py delete mode 100644 homeassistant/components/image/recorder.py delete mode 100644 homeassistant/components/input_boolean/recorder.py delete mode 100644 homeassistant/components/input_button/recorder.py delete mode 100644 homeassistant/components/input_datetime/recorder.py delete mode 100644 homeassistant/components/input_number/recorder.py delete mode 100644 homeassistant/components/input_select/recorder.py delete mode 100644 homeassistant/components/input_text/recorder.py delete mode 100644 homeassistant/components/light/recorder.py delete mode 100644 homeassistant/components/media_player/recorder.py delete mode 100644 homeassistant/components/number/recorder.py delete mode 100644 homeassistant/components/person/recorder.py delete mode 100644 homeassistant/components/schedule/recorder.py delete mode 100644 homeassistant/components/script/recorder.py delete mode 100644 homeassistant/components/select/recorder.py delete mode 100644 homeassistant/components/siren/recorder.py delete mode 100644 homeassistant/components/sun/recorder.py delete mode 100644 homeassistant/components/text/recorder.py delete mode 100644 homeassistant/components/trafikverket_camera/recorder.py delete mode 100644 homeassistant/components/unifiprotect/recorder.py delete mode 100644 homeassistant/components/update/recorder.py delete mode 100644 homeassistant/components/vacuum/recorder.py delete mode 100644 homeassistant/components/water_heater/recorder.py delete mode 100644 homeassistant/components/weather/recorder.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index fd6a70cce46..df388e52a7f 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -57,9 +57,6 @@ from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( @@ -249,10 +246,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: LOGGER, DOMAIN, hass ) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - # Register automation as valid domain for Blueprint async_get_blueprints(hass) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index e487569453f..96872e039e1 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -481,6 +481,8 @@ def is_offset_reached( class CalendarEntity(Entity): """Base class for calendar event entities.""" + _entity_component_unrecorded_attributes = frozenset({"description"}) + _alarm_unsubs: list[CALLBACK_TYPE] = [] @property diff --git a/homeassistant/components/calendar/recorder.py b/homeassistant/components/calendar/recorder.py deleted file mode 100644 index 4aba7b409cc..00000000000 --- a/homeassistant/components/calendar/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude potentially large attributes from being recorded in the database.""" - return {"description"} diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 07394ca75b2..bb5a44a530c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -449,6 +449,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class Camera(Entity): """The base class for camera entities.""" + _entity_component_unrecorded_attributes = frozenset( + {"access_token", "entity_picture"} + ) + # Entity Properties _attr_brand: str | None = None _attr_frame_interval: float = MIN_STREAM_INTERVAL diff --git a/homeassistant/components/camera/recorder.py b/homeassistant/components/camera/recorder.py deleted file mode 100644 index 5c141220881..00000000000 --- a/homeassistant/components/camera/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude access_token and entity_picture from being recorded in the database.""" - return {"access_token", "entity_picture"} diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index dfc428a9bd0..a075467a313 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -209,6 +209,20 @@ class ClimateEntityDescription(EntityDescription): class ClimateEntity(Entity): """Base class for climate entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_HVAC_MODES, + ATTR_FAN_MODES, + ATTR_SWING_MODES, + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_MIN_HUMIDITY, + ATTR_MAX_HUMIDITY, + ATTR_TARGET_TEMP_STEP, + ATTR_PRESET_MODES, + } + ) + entity_description: ClimateEntityDescription _attr_current_humidity: int | None = None _attr_current_temperature: float | None = None diff --git a/homeassistant/components/climate/recorder.py b/homeassistant/components/climate/recorder.py deleted file mode 100644 index 879e6bfbbac..00000000000 --- a/homeassistant/components/climate/recorder.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from .const import ( - ATTR_FAN_MODES, - ATTR_HVAC_MODES, - ATTR_MAX_HUMIDITY, - ATTR_MAX_TEMP, - ATTR_MIN_HUMIDITY, - ATTR_MIN_TEMP, - ATTR_PRESET_MODES, - ATTR_SWING_MODES, - ATTR_TARGET_TEMP_STEP, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_HVAC_MODES, - ATTR_FAN_MODES, - ATTR_SWING_MODES, - ATTR_MIN_TEMP, - ATTR_MAX_TEMP, - ATTR_MIN_HUMIDITY, - ATTR_MAX_HUMIDITY, - ATTR_TARGET_TEMP_STEP, - ATTR_PRESET_MODES, - } diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index f6ba2d79bfe..d9608670972 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -105,6 +105,8 @@ class EventExtraStoredData(ExtraStoredData): class EventEntity(RestoreEntity): """Representation of an Event entity.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_EVENT_TYPES}) + entity_description: EventEntityDescription _attr_device_class: EventDeviceClass | None _attr_event_types: list[str] diff --git a/homeassistant/components/event/recorder.py b/homeassistant/components/event/recorder.py deleted file mode 100644 index 759fd80bcf0..00000000000 --- a/homeassistant/components/event/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_EVENT_TYPES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_EVENT_TYPES} diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 6aa29d8b804..a149909e029 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -183,6 +183,8 @@ class FanEntityDescription(ToggleEntityDescription): class FanEntity(ToggleEntity): """Base class for fan entities.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_PRESET_MODES}) + entity_description: FanEntityDescription _attr_current_direction: str | None = None _attr_oscillating: bool | None = None diff --git a/homeassistant/components/fan/recorder.py b/homeassistant/components/fan/recorder.py deleted file mode 100644 index e7305b64f16..00000000000 --- a/homeassistant/components/fan/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_PRESET_MODES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_PRESET_MODES} diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index ef011c4308a..364ef15fa5e 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -42,7 +42,6 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, async_process_integration_platforms, ) from homeassistant.helpers.reload import async_reload_integration_platforms @@ -285,8 +284,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in hass.data: hass.data[DOMAIN] = EntityComponent[Group](_LOGGER, DOMAIN, hass) - await async_process_integration_platform_for_component(hass, DOMAIN) - component: EntityComponent[Group] = hass.data[DOMAIN] hass.data[REG_KEY] = GroupIntegrationRegistry() @@ -472,6 +469,8 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None class GroupEntity(Entity): """Representation of a Group of entities.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID}) + _attr_should_poll = False _entity_ids: list[str] @@ -560,6 +559,8 @@ class GroupEntity(Entity): class Group(Entity): """Track a group of entity ids.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_ORDER, ATTR_AUTO}) + _attr_should_poll = False tracking: tuple[str, ...] trackable: tuple[str, ...] diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 3960f400614..bc238519cfa 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -122,6 +122,8 @@ def async_create_preview_media_player( class MediaPlayerGroup(MediaPlayerEntity): """Representation of a Media Group.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID}) + _attr_available: bool = False _attr_should_poll = False diff --git a/homeassistant/components/group/recorder.py b/homeassistant/components/group/recorder.py deleted file mode 100644 index 9138b4ef348..00000000000 --- a/homeassistant/components/group/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_ENTITY_ID, - ATTR_ORDER, - ATTR_AUTO, - } diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index a525c626f14..47745c53394 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -134,6 +134,10 @@ class HumidifierEntityDescription(ToggleEntityDescription): class HumidifierEntity(ToggleEntity): """Base class for humidifier entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MIN_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_AVAILABLE_MODES} + ) + entity_description: HumidifierEntityDescription _attr_action: HumidifierAction | None = None _attr_available_modes: list[str] | None diff --git a/homeassistant/components/humidifier/recorder.py b/homeassistant/components/humidifier/recorder.py deleted file mode 100644 index 53df96605d6..00000000000 --- a/homeassistant/components/humidifier/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AVAILABLE_MODES, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_MIN_HUMIDITY, - ATTR_MAX_HUMIDITY, - ATTR_AVAILABLE_MODES, - } diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index d1895053f02..e5c40affe0f 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -126,6 +126,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class ImageEntity(Entity): """The base class for image entities.""" + _entity_component_unrecorded_attributes = frozenset( + {"access_token", "entity_picture"} + ) + # Entity Properties _attr_content_type: str = DEFAULT_CONTENT_TYPE _attr_image_last_updated: datetime | None = None diff --git a/homeassistant/components/image/recorder.py b/homeassistant/components/image/recorder.py deleted file mode 100644 index 5c141220881..00000000000 --- a/homeassistant/components/image/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude access_token and entity_picture from being recorded in the database.""" - return {"access_token", "entity_picture"} diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index a074b3b9b65..613e8829aa1 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -22,9 +22,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -94,10 +91,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input boolean.""" component = EntityComponent[InputBoolean](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -156,6 +149,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity): """Representation of a boolean input.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_boolean/recorder.py b/homeassistant/components/input_boolean/recorder.py deleted file mode 100644 index 8e94dc93f3b..00000000000 --- a/homeassistant/components/input_boolean/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index c04b18b0c25..3318354392c 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -18,9 +18,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -79,10 +76,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input button.""" component = EntityComponent[InputButton](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -137,6 +130,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class InputButton(collection.CollectionEntity, ButtonEntity, RestoreEntity): """Representation of a button.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_button/recorder.py b/homeassistant/components/input_button/recorder.py deleted file mode 100644 index 8e94dc93f3b..00000000000 --- a/homeassistant/components/input_button/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 81882137fad..73a4df12d03 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -20,9 +20,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -132,10 +129,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input datetime.""" component = EntityComponent[InputDatetime](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -225,6 +218,8 @@ class DateTimeStorageCollection(collection.DictStorageCollection): class InputDatetime(collection.CollectionEntity, RestoreEntity): """Representation of a datetime input.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE, CONF_HAS_DATE, CONF_HAS_TIME}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_datetime/recorder.py b/homeassistant/components/input_datetime/recorder.py deleted file mode 100644 index 91c33ee0811..00000000000 --- a/homeassistant/components/input_datetime/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import CONF_HAS_DATE, CONF_HAS_TIME - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude some attributes from being recorded in the database.""" - return {ATTR_EDITABLE, CONF_HAS_DATE, CONF_HAS_TIME} diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 197a35246d2..4a74201be15 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -21,9 +21,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -110,10 +107,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input slider.""" component = EntityComponent[InputNumber](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -209,6 +202,10 @@ class NumberStorageCollection(collection.DictStorageCollection): class InputNumber(collection.CollectionEntity, RestoreEntity): """Representation of a slider.""" + _unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP} + ) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_number/recorder.py b/homeassistant/components/input_number/recorder.py deleted file mode 100644 index 05a5023be0b..00000000000 --- a/homeassistant/components/input_number/recorder.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_MAX, - ATTR_MIN, - ATTR_MODE, - ATTR_STEP, - } diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index e1354cb26a5..4a384e0c17a 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -29,9 +29,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -138,10 +135,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input select.""" component = EntityComponent[InputSelect](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -255,6 +248,11 @@ class InputSelectStorageCollection(collection.DictStorageCollection): class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Representation of a select input.""" + _entity_component_unrecorded_attributes = ( + SelectEntity._entity_component_unrecorded_attributes - {ATTR_OPTIONS} + ) + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_select/recorder.py b/homeassistant/components/input_select/recorder.py deleted file mode 100644 index 8e94dc93f3b..00000000000 --- a/homeassistant/components/input_select/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 096e7cbb105..81b75458dc1 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -20,9 +20,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -110,10 +107,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input text.""" component = EntityComponent[InputText](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -187,6 +180,10 @@ class InputTextStorageCollection(collection.DictStorageCollection): class InputText(collection.CollectionEntity, RestoreEntity): """Represent a text box.""" + _unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} + ) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_text/recorder.py b/homeassistant/components/input_text/recorder.py deleted file mode 100644 index 0f4969270d0..00000000000 --- a/homeassistant/components/input_text/recorder.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_MAX, - ATTR_MIN, - ATTR_MODE, - ATTR_PATTERN, - } diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index f7f0150bdd2..cfcb1e13a07 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -785,6 +785,17 @@ class LightEntityDescription(ToggleEntityDescription): class LightEntity(ToggleEntity): """Base class for light entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_SUPPORTED_COLOR_MODES, + ATTR_EFFECT_LIST, + ATTR_MIN_MIREDS, + ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_MAX_COLOR_TEMP_KELVIN, + } + ) + entity_description: LightEntityDescription _attr_brightness: int | None = None _attr_color_mode: ColorMode | str | None = None diff --git a/homeassistant/components/light/recorder.py b/homeassistant/components/light/recorder.py deleted file mode 100644 index e38ba888e71..00000000000 --- a/homeassistant/components/light/recorder.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ( - ATTR_EFFECT_LIST, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MAX_MIREDS, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MIN_MIREDS, - ATTR_SUPPORTED_COLOR_MODES, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_SUPPORTED_COLOR_MODES, - ATTR_EFFECT_LIST, - ATTR_MIN_MIREDS, - ATTR_MAX_MIREDS, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MAX_COLOR_TEMP_KELVIN, - } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2acb516fa95..f3ff925a1a4 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -27,6 +27,7 @@ from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 + ATTR_ENTITY_PICTURE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -458,6 +459,17 @@ class MediaPlayerEntityDescription(EntityDescription): class MediaPlayerEntity(Entity): """ABC for media player entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_ENTITY_PICTURE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_POSITION, + ATTR_SOUND_MODE_LIST, + } + ) + entity_description: MediaPlayerEntityDescription _access_token: str | None = None diff --git a/homeassistant/components/media_player/recorder.py b/homeassistant/components/media_player/recorder.py deleted file mode 100644 index 8ced833ebec..00000000000 --- a/homeassistant/components/media_player/recorder.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import HomeAssistant, callback - -from . import ( - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_SOUND_MODE_LIST, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static and token attributes from being recorded in the database.""" - return { - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_ENTITY_PICTURE, - ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_MEDIA_POSITION, - ATTR_SOUND_MODE_LIST, - } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index aa3566c5a95..4e0f5059c90 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -156,6 +156,10 @@ def floor_decimal(value: float, precision: float = 0) -> float: class NumberEntity(Entity): """Representation of a Number entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MIN, ATTR_MAX, ATTR_STEP, ATTR_MODE} + ) + entity_description: NumberEntityDescription _attr_device_class: NumberDeviceClass | None _attr_max_value: None diff --git a/homeassistant/components/number/recorder.py b/homeassistant/components/number/recorder.py deleted file mode 100644 index 39418a48878..00000000000 --- a/homeassistant/components/number/recorder.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_MIN, - ATTR_MAX, - ATTR_STEP, - ATTR_MODE, - } diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index ea325380e11..49b719a5490 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -47,9 +47,6 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -333,9 +330,6 @@ The following persons point at invalid users: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the person component.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) entity_component = EntityComponent[Person](_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -397,6 +391,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class Person(collection.CollectionEntity, RestoreEntity): """Represent a tracked person.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_DEVICE_TRACKERS}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/person/recorder.py b/homeassistant/components/person/recorder.py deleted file mode 100644 index 7c0fdf52258..00000000000 --- a/homeassistant/components/person/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_DEVICE_TRACKERS - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude large and chatty update attributes from being recorded.""" - return {ATTR_DEVICE_TRACKERS} diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 2e5fcc27715..2f7831fedd4 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -30,9 +30,6 @@ from homeassistant.helpers.collection import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -157,10 +154,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input select.""" component = EntityComponent[Schedule](LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = IDManager() yaml_collection = YamlCollection(LOGGER, id_manager) @@ -240,6 +233,10 @@ class ScheduleStorageCollection(DictStorageCollection): class Schedule(CollectionEntity): """Schedule entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_NEXT_EVENT} + ) + _attr_has_entity_name = True _attr_should_poll = False _attr_state: Literal["on", "off"] diff --git a/homeassistant/components/schedule/recorder.py b/homeassistant/components/schedule/recorder.py deleted file mode 100644 index b9911e0544b..00000000000 --- a/homeassistant/components/schedule/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_NEXT_EVENT - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude configuration to be recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_NEXT_EVENT, - } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 13b25a00053..716f0197c8b 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -42,9 +42,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( ATTR_CUR, @@ -188,10 +185,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: LOGGER, DOMAIN, hass ) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - # Register script as valid domain for Blueprint async_get_blueprints(hass) @@ -382,6 +375,10 @@ async def _async_process_config( class BaseScriptEntity(ToggleEntity, ABC): """Base class for script entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, ATTR_LAST_ACTION} + ) + raw_config: ConfigType | None @property diff --git a/homeassistant/components/script/recorder.py b/homeassistant/components/script/recorder.py deleted file mode 100644 index b1afc318b51..00000000000 --- a/homeassistant/components/script/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_CUR, ATTR_LAST_ACTION, ATTR_LAST_TRIGGERED, ATTR_MAX, ATTR_MODE - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude extra attributes from being recorded in the database.""" - return {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, ATTR_LAST_ACTION} diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index a8034588ed1..4997e088a54 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -128,6 +128,8 @@ class SelectEntityDescription(EntityDescription): class SelectEntity(Entity): """Representation of a Select entity.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) + entity_description: SelectEntityDescription _attr_current_option: str | None _attr_options: list[str] diff --git a/homeassistant/components/select/recorder.py b/homeassistant/components/select/recorder.py deleted file mode 100644 index 6660c8383d0..00000000000 --- a/homeassistant/components/select/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_OPTIONS - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_OPTIONS} diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6b4e4a17fc2..4faeca33df5 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -149,6 +149,8 @@ class SensorEntityDescription(EntityDescription): class SensorEntity(Entity): """Base class for sensor entities.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) + entity_description: SensorEntityDescription _attr_device_class: SensorDeviceClass | None _attr_last_reset: datetime | None diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 63096b16cd8..2ef1b6854fc 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -30,19 +30,13 @@ from homeassistant.const import ( UnitOfSoundPressure, UnitOfVolume, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import ( - ATTR_LAST_RESET, - ATTR_OPTIONS, - ATTR_STATE_CLASS, - DOMAIN, - SensorStateClass, -) +from .const import ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN, SensorStateClass _LOGGER = logging.getLogger(__name__) @@ -790,9 +784,3 @@ def validate_statistics( ) return validation_result - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude attributes from being recorded in the database.""" - return {ATTR_OPTIONS} diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index a8907ba3b68..ac02201b928 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -159,6 +159,8 @@ class SirenEntityDescription(ToggleEntityDescription): class SirenEntity(ToggleEntity): """Representation of a siren device.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_AVAILABLE_TONES}) + entity_description: SirenEntityDescription _attr_available_tones: list[int | str] | dict[int, str] | None _attr_supported_features: SirenEntityFeature = SirenEntityFeature(0) diff --git a/homeassistant/components/siren/recorder.py b/homeassistant/components/siren/recorder.py deleted file mode 100644 index 3daf4fc52b2..00000000000 --- a/homeassistant/components/siren/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AVAILABLE_TONES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_AVAILABLE_TONES} diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index de1c545739f..5bb105f8123 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -17,9 +17,6 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, event from homeassistant.helpers.entity import Entity -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.sun import ( get_astral_location, get_location_astral_event_next, @@ -97,9 +94,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) hass.data[DOMAIN] = Sun(hass) await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True @@ -119,6 +113,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class Sun(Entity): """Representation of the Sun.""" + _unrecorded_attributes = frozenset( + { + STATE_ATTR_AZIMUTH, + STATE_ATTR_ELEVATION, + STATE_ATTR_RISING, + STATE_ATTR_NEXT_DAWN, + STATE_ATTR_NEXT_DUSK, + STATE_ATTR_NEXT_MIDNIGHT, + STATE_ATTR_NEXT_NOON, + STATE_ATTR_NEXT_RISING, + STATE_ATTR_NEXT_SETTING, + } + ) + _attr_name = "Sun" entity_id = ENTITY_ID # This entity is legacy and does not have a platform. @@ -143,6 +151,12 @@ class Sun(Entity): self.hass = hass self.phase: str | None = None + # This is normally done by async_internal_added_to_hass which is not called + # for sun because sun has no platform + self._state_info = { + "unrecorded_attributes": self._Entity__combined_unrecorded_attributes # type: ignore[attr-defined] + } + self._config_listener: CALLBACK_TYPE | None = None self._update_events_listener: CALLBACK_TYPE | None = None self._update_sun_position_listener: CALLBACK_TYPE | None = None diff --git a/homeassistant/components/sun/recorder.py b/homeassistant/components/sun/recorder.py deleted file mode 100644 index 710d7ff4559..00000000000 --- a/homeassistant/components/sun/recorder.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ( - STATE_ATTR_AZIMUTH, - STATE_ATTR_ELEVATION, - STATE_ATTR_NEXT_DAWN, - STATE_ATTR_NEXT_DUSK, - STATE_ATTR_NEXT_MIDNIGHT, - STATE_ATTR_NEXT_NOON, - STATE_ATTR_NEXT_RISING, - STATE_ATTR_NEXT_SETTING, - STATE_ATTR_RISING, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude sun attributes from being recorded in the database.""" - return { - STATE_ATTR_AZIMUTH, - STATE_ATTR_ELEVATION, - STATE_ATTR_RISING, - STATE_ATTR_NEXT_DAWN, - STATE_ATTR_NEXT_DUSK, - STATE_ATTR_NEXT_MIDNIGHT, - STATE_ATTR_NEXT_NOON, - STATE_ATTR_NEXT_RISING, - STATE_ATTR_NEXT_SETTING, - } diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 4182b177bf6..acc5f62a0cc 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -111,6 +111,10 @@ class TextEntityDescription(EntityDescription): class TextEntity(Entity): """Representation of a Text entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} + ) + entity_description: TextEntityDescription _attr_mode: TextMode _attr_native_value: str | None diff --git a/homeassistant/components/text/recorder.py b/homeassistant/components/text/recorder.py deleted file mode 100644 index 09642eb3079..00000000000 --- a/homeassistant/components/text/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index dfac8416c49..5575f32788a 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -4,10 +4,6 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator @@ -15,14 +11,6 @@ from .coordinator import TVDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up trafikverket_camera.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trafikverket Camera from a config entry.""" diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index a7da3db1433..808d687a131 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -37,6 +37,8 @@ async def async_setup_entry( class TVCamera(TrafikverketCameraEntity, Camera): """Implement Trafikverket camera.""" + _unrecorded_attributes = frozenset({ATTR_DESCRIPTION, ATTR_LOCATION}) + _attr_name = None _attr_translation_key = "tv_camera" coordinator: TVDataUpdateCoordinator diff --git a/homeassistant/components/trafikverket_camera/recorder.py b/homeassistant/components/trafikverket_camera/recorder.py deleted file mode 100644 index b6b608749ad..00000000000 --- a/homeassistant/components/trafikverket_camera/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_LOCATION -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_DESCRIPTION - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude description and location from being recorded in the database.""" - return {ATTR_DESCRIPTION, ATTR_LOCATION} diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index d42e611be7e..28149d349c9 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -311,6 +311,8 @@ class ProtectNVREntity(ProtectDeviceEntity): class EventEntityMixin(ProtectDeviceEntity): """Adds motion event attributes to sensor.""" + _unrecorded_attributes = frozenset({ATTR_EVENT_ID, ATTR_EVENT_SCORE}) + entity_description: ProtectEventMixin def __init__( diff --git a/homeassistant/components/unifiprotect/recorder.py b/homeassistant/components/unifiprotect/recorder.py deleted file mode 100644 index 6603a0543f8..00000000000 --- a/homeassistant/components/unifiprotect/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_EVENT_ID, ATTR_EVENT_SCORE - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude event_id and event_score from being recorded in the database.""" - return {ATTR_EVENT_ID, ATTR_EVENT_SCORE} diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e23032e24fe..c9496ce8f7b 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import ATTR_ENTITY_PICTURE, STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -192,6 +192,10 @@ def _version_is_newer(latest_version: str, installed_version: str) -> bool: class UpdateEntity(RestoreEntity): """Representation of an update entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_ENTITY_PICTURE, ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY} + ) + entity_description: UpdateEntityDescription _attr_auto_update: bool = False _attr_installed_version: str | None = None diff --git a/homeassistant/components/update/recorder.py b/homeassistant/components/update/recorder.py deleted file mode 100644 index 408937c4f31..00000000000 --- a/homeassistant/components/update/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude large and chatty update attributes from being recorded.""" - return {ATTR_ENTITY_PICTURE, ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY} diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 8285e1d76d1..68d50d1c2fc 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -228,6 +228,8 @@ class _BaseVacuum(Entity): Contains common properties and functions for all vacuum devices. """ + _entity_component_unrecorded_attributes = frozenset({ATTR_FAN_SPEED_LIST}) + _attr_battery_icon: str _attr_battery_level: int | None = None _attr_fan_speed: str | None = None diff --git a/homeassistant/components/vacuum/recorder.py b/homeassistant/components/vacuum/recorder.py deleted file mode 100644 index 7dc7e9e0408..00000000000 --- a/homeassistant/components/vacuum/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_FAN_SPEED_LIST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_FAN_SPEED_LIST} diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index b31d1306c55..9e796092f6a 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -164,6 +164,10 @@ class WaterHeaterEntityEntityDescription(EntityDescription): class WaterHeaterEntity(Entity): """Base class for water heater entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} + ) + entity_description: WaterHeaterEntityEntityDescription _attr_current_operation: str | None = None _attr_current_temperature: float | None = None diff --git a/homeassistant/components/water_heater/recorder.py b/homeassistant/components/water_heater/recorder.py deleted file mode 100644 index d76b96936fa..00000000000 --- a/homeassistant/components/water_heater/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_OPERATION_LIST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 0d72dbb825e..4ec9ea91f89 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -264,6 +264,8 @@ class PostInit(metaclass=PostInitMeta): class WeatherEntity(Entity, PostInit): """ABC for weather data.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_FORECAST}) + entity_description: WeatherEntityDescription _attr_condition: str | None = None # _attr_forecast is deprecated, implement async_forecast_daily, diff --git a/homeassistant/components/weather/recorder.py b/homeassistant/components/weather/recorder.py deleted file mode 100644 index 1c887ea5202..00000000000 --- a/homeassistant/components/weather/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_FORECAST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude (often large) forecasts from being recorded in the database.""" - return {ATTR_FORECAST} From c170babba62d746f1dbf7ffaf42b56eea2cbcc4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Janu=C3=A1rio?= Date: Thu, 21 Sep 2023 14:18:55 +0100 Subject: [PATCH 678/984] Add ecoforest integration (#100647) * Add ecoforest integration * fix file title * remove host default from schema, hints will be given in the documentation * moved input validation to async_step_user * ensure we can receive device data while doing entry setup * remove unecessary check before unique id is set * added shorter syntax for async create entry Co-authored-by: Joost Lekkerkerker * use variable to set unique id Co-authored-by: Joost Lekkerkerker * Use _attr_has_entity_name from base entity Co-authored-by: Joost Lekkerkerker * remove unecessary comments in coordinator * use shorthand for device information * remove empty objects from manifest * remove unecessary flag Co-authored-by: Joost Lekkerkerker * use _async_abort_entries_match to ensure device is not duplicated * remove unecessary host attr * fixed coordinator host attr to be used by entities to identify device * remove unecessary assert * use default device class temperature trasnlation key * reuse base entity description * use device serial number as identifier * remove unused code * Improve logging message Co-authored-by: Joost Lekkerkerker * Remove unused errors Co-authored-by: Joost Lekkerkerker * Raise a generic update failed Co-authored-by: Joost Lekkerkerker * use coordinator directly Co-authored-by: Joost Lekkerkerker * No need to check for serial number Co-authored-by: Joost Lekkerkerker * rename variable Co-authored-by: Joost Lekkerkerker * use renamed variable Co-authored-by: Joost Lekkerkerker * improve assertion Co-authored-by: Joost Lekkerkerker * use serial number in entity unique id Co-authored-by: Joost Lekkerkerker * raise config entry not ready on setup when error in connection * improve test readability * Improve python syntax Co-authored-by: Joost Lekkerkerker * abort when device already configured with same serial number * improve tests * fix test name * use coordinator data Co-authored-by: Joost Lekkerkerker * improve asserts Co-authored-by: Joost Lekkerkerker * fix ci * improve error handling --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 4 + CODEOWNERS | 2 + .../components/ecoforest/__init__.py | 59 +++++++++ .../components/ecoforest/config_flow.py | 63 ++++++++++ homeassistant/components/ecoforest/const.py | 8 ++ .../components/ecoforest/coordinator.py | 39 ++++++ homeassistant/components/ecoforest/entity.py | 42 +++++++ .../components/ecoforest/manifest.json | 9 ++ homeassistant/components/ecoforest/sensor.py | 72 +++++++++++ .../components/ecoforest/strings.json | 21 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ecoforest/__init__.py | 1 + tests/components/ecoforest/conftest.py | 73 +++++++++++ .../components/ecoforest/test_config_flow.py | 115 ++++++++++++++++++ 17 files changed, 521 insertions(+) create mode 100644 homeassistant/components/ecoforest/__init__.py create mode 100644 homeassistant/components/ecoforest/config_flow.py create mode 100644 homeassistant/components/ecoforest/const.py create mode 100644 homeassistant/components/ecoforest/coordinator.py create mode 100644 homeassistant/components/ecoforest/entity.py create mode 100644 homeassistant/components/ecoforest/manifest.json create mode 100644 homeassistant/components/ecoforest/sensor.py create mode 100644 homeassistant/components/ecoforest/strings.json create mode 100644 tests/components/ecoforest/__init__.py create mode 100644 tests/components/ecoforest/conftest.py create mode 100644 tests/components/ecoforest/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index ac08240fd0f..5cdb7ec1a14 100644 --- a/.coveragerc +++ b/.coveragerc @@ -258,6 +258,10 @@ omit = homeassistant/components/ecobee/notify.py homeassistant/components/ecobee/sensor.py homeassistant/components/ecobee/weather.py + homeassistant/components/ecoforest/__init__.py + homeassistant/components/ecoforest/coordinator.py + homeassistant/components/ecoforest/entity.py + homeassistant/components/ecoforest/sensor.py homeassistant/components/econet/__init__.py homeassistant/components/econet/binary_sensor.py homeassistant/components/econet/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 5bd97369ef5..4fdf8845fe9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -309,6 +309,8 @@ build.json @home-assistant/supervisor /tests/components/easyenergy/ @klaasnicolaas /homeassistant/components/ecobee/ @marthoc @marcolivierarsenault /tests/components/ecobee/ @marthoc @marcolivierarsenault +/homeassistant/components/ecoforest/ @pjanuario +/tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @vangorra @w1ll1am23 /tests/components/econet/ @vangorra @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT @mib1185 diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py new file mode 100644 index 00000000000..cc5575248fe --- /dev/null +++ b/homeassistant/components/ecoforest/__init__.py @@ -0,0 +1,59 @@ +"""The Ecoforest integration.""" +from __future__ import annotations + +import logging + +import httpx +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import ( + EcoforestAuthenticationRequired, + EcoforestConnectionError, +) + +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 +from .coordinator import EcoforestCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ecoforest from a config entry.""" + + host = entry.data[CONF_HOST] + auth = httpx.BasicAuth(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + api = EcoforestApi(host, auth) + + try: + device = await api.get() + _LOGGER.debug("Ecoforest: %s", device) + except EcoforestAuthenticationRequired: + _LOGGER.error("Authentication on device %s failed", host) + return False + except EcoforestConnectionError as err: + _LOGGER.error("Error communicating with device %s", host) + raise ConfigEntryNotReady from err + + coordinator = EcoforestCoordinator(hass, api) + + 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) + + 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/ecoforest/config_flow.py b/homeassistant/components/ecoforest/config_flow.py new file mode 100644 index 00000000000..0afc46c2370 --- /dev/null +++ b/homeassistant/components/ecoforest/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Ecoforest integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from httpx import BasicAuth +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import EcoforestAuthenticationRequired +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, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ecoforest.""" + + 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: + try: + api = EcoforestApi( + user_input[CONF_HOST], + BasicAuth(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]), + ) + device = await api.get() + except EcoforestAuthenticationRequired: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {device.serial_number}", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/ecoforest/const.py b/homeassistant/components/ecoforest/const.py new file mode 100644 index 00000000000..8f8bbdcb45a --- /dev/null +++ b/homeassistant/components/ecoforest/const.py @@ -0,0 +1,8 @@ +"""Constants for the Ecoforest integration.""" + +from datetime import timedelta + +DOMAIN = "ecoforest" +MANUFACTURER = "Ecoforest" + +POLLING_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py new file mode 100644 index 00000000000..b44ccc850ce --- /dev/null +++ b/homeassistant/components/ecoforest/coordinator.py @@ -0,0 +1,39 @@ +"""The ecoforest coordinator.""" + + +import logging + +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import EcoforestError +from pyecoforest.models.device import Device + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import POLLING_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class EcoforestCoordinator(DataUpdateCoordinator[Device]): + """DataUpdateCoordinator to gather data from ecoforest device.""" + + def __init__(self, hass: HomeAssistant, api: EcoforestApi) -> None: + """Initialize DataUpdateCoordinator.""" + + super().__init__( + hass, + _LOGGER, + name="ecoforest", + update_interval=POLLING_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> Device: + """Fetch all device and sensor data from api.""" + try: + data = await self.api.get() + _LOGGER.debug("Ecoforest data: %s", data) + return data + except EcoforestError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/ecoforest/entity.py b/homeassistant/components/ecoforest/entity.py new file mode 100644 index 00000000000..901ed1bf4bf --- /dev/null +++ b/homeassistant/components/ecoforest/entity.py @@ -0,0 +1,42 @@ +"""Base Entity for Ecoforest.""" +from __future__ import annotations + +from pyecoforest.models.device import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import EcoforestCoordinator + + +class EcoforestEntity(CoordinatorEntity[EcoforestCoordinator]): + """Common Ecoforest entity using CoordinatorEntity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EcoforestCoordinator, + description: EntityDescription, + ) -> None: + """Initialize device information.""" + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}_{description.key}" + + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.serial_number)}, + name=MANUFACTURER, + model=coordinator.data.model_name, + sw_version=coordinator.data.firmware, + manufacturer=MANUFACTURER, + ) + + @property + def data(self) -> Device: + """Return ecoforest data.""" + assert self.coordinator.data + return self.coordinator.data diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json new file mode 100644 index 00000000000..518f4d97a04 --- /dev/null +++ b/homeassistant/components/ecoforest/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ecoforest", + "name": "Ecoforest", + "codeowners": ["@pjanuario"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ecoforest", + "iot_class": "local_polling", + "requirements": ["pyecoforest==0.3.0"] +} diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py new file mode 100644 index 00000000000..bba0a360375 --- /dev/null +++ b/homeassistant/components/ecoforest/sensor.py @@ -0,0 +1,72 @@ +"""Support for Ecoforest sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyecoforest.models.device import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EcoforestRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], float | None] + + +@dataclass +class EcoforestSensorEntityDescription( + SensorEntityDescription, EcoforestRequiredKeysMixin +): + """Describes Ecoforest sensor entity.""" + + +SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( + EcoforestSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda data: data.environment_temperature, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Ecoforest sensor platform.""" + coordinator: EcoforestCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + EcoforestSensor(coordinator, description) for description in SENSOR_TYPES + ] + + async_add_entities(entities) + + +class EcoforestSensor(SensorEntity, EcoforestEntity): + """Representation of an Ecoforest sensor.""" + + entity_description: EcoforestSensorEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json new file mode 100644 index 00000000000..d6e3212b4ea --- /dev/null +++ b/homeassistant/components/ecoforest/strings.json @@ -0,0 +1,21 @@ +{ + "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%]", + "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 3f37f3a19df..54089723e21 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -114,6 +114,7 @@ FLOWS = { "eafm", "easyenergy", "ecobee", + "ecoforest", "econet", "ecowitt", "edl21", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8c7defb6969..aac00cdd0d8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1311,6 +1311,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ecoforest": { + "name": "Ecoforest", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "econet": { "name": "Rheem EcoNet Products", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c059d20cbd5..1367844c418 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1668,6 +1668,9 @@ pydroid-ipcam==2.0.0 # homeassistant.components.ebox pyebox==1.1.4 +# homeassistant.components.ecoforest +pyecoforest==0.3.0 + # homeassistant.components.econet pyeconet==0.1.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbd433aa4c0..cf76db0b1b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1244,6 +1244,9 @@ pydiscovergy==2.0.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 +# homeassistant.components.ecoforest +pyecoforest==0.3.0 + # homeassistant.components.econet pyeconet==0.1.20 diff --git a/tests/components/ecoforest/__init__.py b/tests/components/ecoforest/__init__.py new file mode 100644 index 00000000000..031cba659d2 --- /dev/null +++ b/tests/components/ecoforest/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ecoforest integration.""" diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py new file mode 100644 index 00000000000..09860546c15 --- /dev/null +++ b/tests/components/ecoforest/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures for the Ecoforest tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from pyecoforest.models.device import Alarm, Device, OperationMode, State +import pytest + +from homeassistant.components.ecoforest import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ecoforest.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config") +def config_fixture(): + """Define a config entry data fixture.""" + return { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + + +@pytest.fixture(name="serial_number") +def serial_number_fixture(): + """Define a serial number fixture.""" + return "1234" + + +@pytest.fixture(name="mock_device") +def mock_device_fixture(serial_number): + """Define a mocked Ecoforest device fixture.""" + mock = Mock(spec=Device) + mock.model = "model-version" + mock.model_name = "model-name" + mock.firmware = "firmware-version" + mock.serial_number = serial_number + mock.operation_mode = OperationMode.POWER + mock.on = False + mock.state = State.OFF + mock.power = 3 + mock.temperature = 21.5 + mock.alarm = Alarm.PELLETS + mock.alarm_code = "A099" + mock.environment_temperature = 23.5 + mock.cpu_temperature = 36.1 + mock.gas_temperature = 40.2 + mock.ntc_temperature = 24.2 + return mock + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config, serial_number): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title=f"Ecoforest {serial_number}", + unique_id=serial_number, + data=config, + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/ecoforest/test_config_flow.py b/tests/components/ecoforest/test_config_flow.py new file mode 100644 index 00000000000..302cbe76fa9 --- /dev/null +++ b/tests/components/ecoforest/test_config_flow.py @@ -0,0 +1,115 @@ +"""Test the Ecoforest config flow.""" +from unittest.mock import AsyncMock, patch + +from pyecoforest.exceptions import EcoforestAuthenticationRequired +import pytest + +from homeassistant import config_entries +from homeassistant.components.ecoforest.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, mock_device, config +) -> 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( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "result" in result + assert result["result"].unique_id == "1234" + assert result["title"] == "Ecoforest 1234" + 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_device_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry, mock_device, config +) -> None: + """Test device already exists.""" + 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( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + EcoforestAuthenticationRequired("401"), + "invalid_auth", + ), + ( + Exception("Something wrong"), + "cannot_connect", + ), + ], +) +async def test_flow_fails( + hass: HomeAssistant, error: Exception, message: str, mock_device, config +) -> None: + """Test we handle failed flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyecoforest.api.EcoforestApi.get", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + + with patch( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY From a08b74c550fc2e615a234b2b7add1f915ae6ea2f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 21 Sep 2023 15:20:58 +0200 Subject: [PATCH 679/984] Move coolmaster coordinator to its own file (#100425) --- .coveragerc | 1 + .../components/coolmaster/__init__.py | 29 +---------------- .../components/coolmaster/coordinator.py | 31 +++++++++++++++++++ 3 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/coolmaster/coordinator.py diff --git a/.coveragerc b/.coveragerc index 5cdb7ec1a14..66f7185ee55 100644 --- a/.coveragerc +++ b/.coveragerc @@ -182,6 +182,7 @@ omit = homeassistant/components/control4/__init__.py homeassistant/components/control4/director_utils.py homeassistant/components/control4/light.py + homeassistant/components/coolmaster/coordinator.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/crownstone/__init__.py homeassistant/components/crownstone/devices.py diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 289e70e8067..eaca8949b14 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -1,18 +1,13 @@ """The Coolmaster integration.""" -import logging - from pycoolmasternet_async import CoolMasterNet -from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_SWING_SUPPORT, DATA_COORDINATOR, DATA_INFO, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import CoolmasterDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] @@ -60,25 +55,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Coolmaster data.""" - - def __init__(self, hass, coolmaster): - """Initialize global Coolmaster data updater.""" - self._coolmaster = coolmaster - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from Coolmaster.""" - try: - return await self._coolmaster.status() - except OSError as error: - raise UpdateFailed from error diff --git a/homeassistant/components/coolmaster/coordinator.py b/homeassistant/components/coolmaster/coordinator.py new file mode 100644 index 00000000000..241f287e297 --- /dev/null +++ b/homeassistant/components/coolmaster/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for coolmaster integration.""" +import logging + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Coolmaster data.""" + + def __init__(self, hass, coolmaster): + """Initialize global Coolmaster data updater.""" + self._coolmaster = coolmaster + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Coolmaster.""" + try: + return await self._coolmaster.status() + except OSError as error: + raise UpdateFailed from error From 6e0ab35f85304dbcbddfd12991707bc0902a6d7f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 21 Sep 2023 09:43:17 -0400 Subject: [PATCH 680/984] Add water shortage binary sensor (#100662) --- homeassistant/components/roborock/binary_sensor.py | 8 ++++++++ homeassistant/components/roborock/strings.json | 3 +++ tests/components/roborock/test_binary_sensor.py | 5 ++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index d61c1ee14b9..320b0fc7c6d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -61,6 +61,14 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_box_status, ), + RoborockBinarySensorDescription( + key="water_shortage", + translation_key="water_shortage", + icon="mdi:water", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_shortage_status, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 92d53c2e6bd..982aa78518e 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -36,6 +36,9 @@ }, "water_box_attached": { "name": "Water box attached" + }, + "water_shortage": { + "name": "Water shortage" } }, "number": { diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 310643355b0..4edf8ff4710 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -9,9 +9,12 @@ async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 4 + assert len(hass.states.async_all("binary_sensor")) == 6 assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state == "on" ) + assert ( + hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" + ) From e2bfa9f9cd47ecba6558b700806d44546a85d22c Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 21 Sep 2023 10:00:15 -0400 Subject: [PATCH 681/984] Add last clean sensors to Roborock (#100661) * Add water shortage binary sensor * add last clean sensors * fix tests * fix tests again * remove accidentally added binary sensor --- homeassistant/components/roborock/sensor.py | 16 ++++++++++++++++ homeassistant/components/roborock/strings.json | 6 ++++++ tests/components/roborock/test_sensor.py | 10 +++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index fc2fa6a6e40..8a18c281d59 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -143,6 +143,22 @@ SENSOR_DESCRIPTIONS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + RoborockSensorDescription( + key="last_clean_start", + translation_key="last_clean_start", + icon="mdi:clock-time-twelve", + value_fn=lambda data: data.last_clean_record.begin_datetime, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), + RoborockSensorDescription( + key="last_clean_end", + translation_key="last_clean_end", + icon="mdi:clock-time-twelve", + value_fn=lambda data: data.last_clean_record.end_datetime, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), # Only available on some newer models RoborockSensorDescription( key="clean_percent", diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 982aa78518e..c46eb814151 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -73,6 +73,12 @@ "mop_drying_remaining_time": { "name": "Mop drying remaining time" }, + "last_clean_start": { + "name": "Last clean begin" + }, + "last_clean_end": { + "name": "Last clean end" + }, "side_brush_time_left": { "name": "Side brush time left" }, diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 0089c9a60bd..35fcc9478cd 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 24 + assert len(hass.states.async_all("sensor")) == 28 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -39,3 +39,11 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" + assert ( + hass.states.get("sensor.roborock_s7_maxv_last_clean_begin").state + == "2023-01-01T03:22:10+00:00" + ) + assert ( + hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state + == "2023-01-01T03:43:58+00:00" + ) From 1c7b3cb2d538470db2bf74716e7bc8c95b8598cc Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 21 Sep 2023 17:02:39 +0200 Subject: [PATCH 682/984] ZHA multiprotocol detected - fix typo (#100683) --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 87738e821ea..f5bebb1e963 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -508,7 +508,7 @@ "issues": { "wrong_silabs_firmware_installed_nabucasa": { "title": "Zigbee radio with multiprotocol firmware detected", - "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n -. Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." + "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n - Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." }, "wrong_silabs_firmware_installed_other": { "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", From ab060b86d1597432a9bd35b980180375315660a0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 21 Sep 2023 17:06:41 +0200 Subject: [PATCH 683/984] Remove async_process_integration_platform_for_component (#100680) --- homeassistant/helpers/integration_platform.py | 19 +++---------------- tests/helpers/test_integration_platform.py | 12 ------------ 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index ddaede44962..0a9a6efd525 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -65,23 +65,10 @@ async def _async_process_single_integration_platform_component( ) -async def async_process_integration_platform_for_component( +async def _async_process_integration_platform_for_component( hass: HomeAssistant, component_name: str ) -> None: - """Process integration platforms on demand for a component. - - This function will load the integration platforms - for an integration instead of waiting for the EVENT_COMPONENT_LOADED - event to be fired for the integration. - - When the integration will create entities before - it has finished setting up; call this function to ensure - that the integration platforms are loaded before the entities - are created. - """ - if DATA_INTEGRATION_PLATFORMS not in hass.data: - # There are no integration platforms loaded yet - return + """Process integration platforms for a component.""" integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] @@ -116,7 +103,7 @@ async def async_process_integration_platforms( async def _async_component_loaded(event: Event) -> None: """Handle a new component loaded.""" - await async_process_integration_platform_for_component( + await _async_process_integration_platform_for_component( hass, event.data[ATTR_COMPONENT] ) diff --git a/tests/helpers/test_integration_platform.py b/tests/helpers/test_integration_platform.py index 2dfc0742e26..ed6edcc3690 100644 --- a/tests/helpers/test_integration_platform.py +++ b/tests/helpers/test_integration_platform.py @@ -5,7 +5,6 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, async_process_integration_platforms, ) from homeassistant.setup import ATTR_COMPONENT, EVENT_COMPONENT_LOADED @@ -43,17 +42,6 @@ async def test_process_integration_platforms(hass: HomeAssistant) -> None: assert processed[1][0] == "event" assert processed[1][1] == event_platform - # Verify we only process the platform once if we call it manually - await async_process_integration_platform_for_component(hass, "event") - assert len(processed) == 2 - - -async def test_process_integration_platforms_none_loaded(hass: HomeAssistant) -> None: - """Test processing integrations with none loaded.""" - # Verify we can call async_process_integration_platform_for_component - # when there are none loaded and it does not throw - await async_process_integration_platform_for_component(hass, "any") - async def test_broken_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture From e57156dd9c19f48f4e35c63fc76367ffbea5f579 Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Thu, 21 Sep 2023 17:55:30 +0200 Subject: [PATCH 684/984] Add Renson button entity (#99494) Co-authored-by: Robert Resch --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 1 + homeassistant/components/renson/button.py | 90 +++++++++++++++++++ homeassistant/components/renson/manifest.json | 2 +- homeassistant/components/renson/strings.json | 5 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/renson/button.py diff --git a/.coveragerc b/.coveragerc index 66f7185ee55..5b046a7249d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1020,6 +1020,7 @@ omit = homeassistant/components/renson/coordinator.py homeassistant/components/renson/entity.py homeassistant/components/renson/sensor.py + homeassistant/components/renson/button.py homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py homeassistant/components/renson/number.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 231e63bfc25..2a9c13be543 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -15,6 +15,7 @@ from .coordinator import RensonCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.FAN, Platform.NUMBER, Platform.SENSOR, diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py new file mode 100644 index 00000000000..53d995ba792 --- /dev/null +++ b/homeassistant/components/renson/button.py @@ -0,0 +1,90 @@ +"""Renson ventilation unit buttons.""" +from __future__ import annotations + +from dataclasses import dataclass + +from _collections_abc import Callable +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator, RensonData +from .const import DOMAIN +from .entity import RensonEntity + + +@dataclass +class RensonButtonEntityDescriptionMixin: + """Action function called on press.""" + + action_fn: Callable[[RensonVentilation], None] + + +@dataclass +class RensonButtonEntityDescription( + ButtonEntityDescription, RensonButtonEntityDescriptionMixin +): + """Class describing Renson button entity.""" + + +ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = ( + RensonButtonEntityDescription( + key="sync_time", + entity_category=EntityCategory.CONFIG, + translation_key="sync_time", + action_fn=lambda api: api.sync_time(), + ), + RensonButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + action_fn=lambda api: api.restart_device(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson button platform.""" + + data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + RensonButton(description, data.api, data.coordinator) + for description in ENTITY_DESCRIPTIONS + ] + + async_add_entities(entities) + + +class RensonButton(RensonEntity, ButtonEntity): + """Representation of a Renson actions.""" + + _attr_has_entity_name = True + entity_description: RensonButtonEntityDescription + + def __init__( + self, + description: RensonButtonEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, api, coordinator) + + self.entity_description = description + + def press(self) -> None: + """Triggers the action.""" + self.entity_description.action_fn(self.api) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 5ff219cc26c..1a7f367a946 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.5.0"] + "requirements": ["renson-endura-delta==1.6.0"] } diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 1a4829c2da9..7099cdf2c45 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -13,6 +13,11 @@ } }, "entity": { + "button": { + "sync_time": { + "name": "Sync time with device" + } + }, "number": { "filter_change": { "name": "Filter clean/replacement" diff --git a/requirements_all.txt b/requirements_all.txt index 1367844c418..545e05a7e84 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2301,7 +2301,7 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.5.0 +renson-endura-delta==1.6.0 # homeassistant.components.reolink reolink-aio==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf76db0b1b6..e04213f0c8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1703,7 +1703,7 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.5.0 +renson-endura-delta==1.6.0 # homeassistant.components.reolink reolink-aio==0.7.10 From a609df2914aa92823af47b9f8597e1bff6c78545 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Thu, 21 Sep 2023 19:19:03 +0200 Subject: [PATCH 685/984] Update plugwise to v0.33.0 (#100689) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index e87e1f0c281..c8c678d6aae 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.32.2"], + "requirements": ["plugwise==0.33.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 545e05a7e84..aadc9698915 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1445,7 +1445,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.32.2 +plugwise==0.33.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e04213f0c8e..0bc99890587 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1102,7 +1102,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.32.2 +plugwise==0.33.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From f973d4cc260abb85bcd3aaebcd8f0b0e4ff3bb5f Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 21 Sep 2023 19:23:02 +0200 Subject: [PATCH 686/984] ZHA multiprotocol detected message: add info (#100686) --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index f5bebb1e963..79354325fb2 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -508,7 +508,7 @@ "issues": { "wrong_silabs_firmware_installed_nabucasa": { "title": "Zigbee radio with multiprotocol firmware detected", - "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n - Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." + "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n - Follow the instructions described in Step 2 (and Step 2 only) to 'Flash the Silicon Labs radio Zigbee firmware'.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." }, "wrong_silabs_firmware_installed_other": { "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", From 5cf5f5b4cf044ca07d63a401d91aef741f088290 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Thu, 21 Sep 2023 19:31:53 +0200 Subject: [PATCH 687/984] Add missing step-differentiation for the Plugwise temperature_offset (#100654) --- homeassistant/components/plugwise/number.py | 6 +++++- tests/components/plugwise/test_number.py | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 7e387abea02..9865aec2242 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -114,7 +114,11 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): self._attr_mode = NumberMode.BOX self._attr_native_max_value = self.device[description.key]["upper_bound"] self._attr_native_min_value = self.device[description.key]["lower_bound"] - self._attr_native_step = max(self.device[description.key]["resolution"], 0.5) + + native_step = self.device[description.key]["resolution"] + if description.key != "temperature_offset": + native_step = max(native_step, 0.5) + self._attr_native_step = native_step @property def native_value(self) -> float: diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index 6572a0df20e..6fa65b3e65a 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -71,10 +71,22 @@ async def test_adam_dhw_setpoint_change( ) +async def test_adam_temperature_offset( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test creation of the temperature_offset number.""" + state = hass.states.get("number.zone_thermostat_jessie_temperature_offset") + assert state + assert float(state.state) == 0.0 + assert state.attributes.get("min") == -2.0 + assert state.attributes.get("max") == 2.0 + assert state.attributes.get("step") == 0.1 + + async def test_adam_temperature_offset_change( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test changing of number entities.""" + """Test changing of the temperature_offset number.""" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, From cd302869131d604cb8b9898e84741013c77647e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Janu=C3=A1rio?= Date: Thu, 21 Sep 2023 21:36:39 +0100 Subject: [PATCH 688/984] Add number platform to ecoforest (#100694) * add power number entity to ecoforest integration * fix number.py header * minor fixes * change power to power level * update comment for native value prop Co-authored-by: Joost Lekkerkerker * exclude number.py from coverage --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + .../components/ecoforest/__init__.py | 2 +- homeassistant/components/ecoforest/number.py | 74 +++++++++++++++++++ .../components/ecoforest/strings.json | 7 ++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ecoforest/number.py diff --git a/.coveragerc b/.coveragerc index 5b046a7249d..2eebbee3a3e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -262,6 +262,7 @@ omit = homeassistant/components/ecoforest/__init__.py homeassistant/components/ecoforest/coordinator.py homeassistant/components/ecoforest/entity.py + homeassistant/components/ecoforest/number.py homeassistant/components/ecoforest/sensor.py homeassistant/components/econet/__init__.py homeassistant/components/econet/binary_sensor.py diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py index cc5575248fe..1e2667256b1 100644 --- a/homeassistant/components/ecoforest/__init__.py +++ b/homeassistant/components/ecoforest/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import EcoforestCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py new file mode 100644 index 00000000000..90ea0bd4dff --- /dev/null +++ b/homeassistant/components/ecoforest/number.py @@ -0,0 +1,74 @@ +"""Support for Ecoforest number platform.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyecoforest.models.device import Device + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + + +@dataclass +class EcoforestRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], float | None] + + +@dataclass +class EcoforestNumberEntityDescription( + NumberEntityDescription, EcoforestRequiredKeysMixin +): + """Describes an ecoforest number entity.""" + + +NUMBER_ENTITIES = ( + EcoforestNumberEntityDescription( + key="power_level", + translation_key="power_level", + native_min_value=1, + native_max_value=9, + native_step=1, + value_fn=lambda data: data.power, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Ecoforest number platform.""" + coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + EcoforestNumberEntity(coordinator, description) + for description in NUMBER_ENTITIES + ] + + async_add_entities(entities) + + +class EcoforestNumberEntity(EcoforestEntity, NumberEntity): + """Representation of an Ecoforest number entity.""" + + entity_description: EcoforestNumberEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state of the entity.""" + return self.entity_description.value_fn(self.data) + + async def async_set_native_value(self, value: float) -> None: + """Update the native value.""" + await self.coordinator.api.set_power(int(value)) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index d6e3212b4ea..b35ff1dedf1 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -17,5 +17,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "number": { + "power_level": { + "name": "Power level" + } + } } } From 86a692bb22088a520212c1d540302b9e84f0c1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Janu=C3=A1rio?= Date: Fri, 22 Sep 2023 10:08:09 +0100 Subject: [PATCH 689/984] Add additional sensors to ecoforest integration (#100681) * add ecoforest additional sensors * add ecoforest additional sensors * use StateType Co-authored-by: Joost Lekkerkerker * use StateType Co-authored-by: Joost Lekkerkerker * update cpu temp translation Co-authored-by: Joost Lekkerkerker * use common translation Co-authored-by: Joost Lekkerkerker * use common on translation Co-authored-by: Joost Lekkerkerker * use common standby translation Co-authored-by: Joost Lekkerkerker * update strings * update strings * import state type * relabel preheating Co-authored-by: Joost Lekkerkerker * add cpu temp disable by default --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ecoforest/sensor.py | 49 +++++++++++++++++-- .../components/ecoforest/strings.json | 33 +++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index bba0a360375..91f3138af37 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from pyecoforest.models.device import Device +from pyecoforest.models.device import Alarm, Device, State from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import EcoforestCoordinator @@ -23,12 +24,15 @@ from .entity import EcoforestEntity _LOGGER = logging.getLogger(__name__) +STATUS_TYPE = [s.value for s in State] +ALARM_TYPE = [a.value for a in Alarm] + ["none"] + @dataclass class EcoforestRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[Device], float | None] + value_fn: Callable[[Device], StateType] @dataclass @@ -45,6 +49,45 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, value_fn=lambda data: data.environment_temperature, ), + EcoforestSensorEntityDescription( + key="cpu_temperature", + translation_key="cpu_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.cpu_temperature, + ), + EcoforestSensorEntityDescription( + key="gas_temperature", + translation_key="gas_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.gas_temperature, + ), + EcoforestSensorEntityDescription( + key="ntc_temperature", + translation_key="ntc_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.ntc_temperature, + ), + EcoforestSensorEntityDescription( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + options=STATUS_TYPE, + value_fn=lambda data: data.state.value, + ), + EcoforestSensorEntityDescription( + key="alarm", + translation_key="alarm", + device_class=SensorDeviceClass.ENUM, + options=ALARM_TYPE, + icon="mdi:alert", + value_fn=lambda data: data.alarm.value if data.alarm else "none", + ), ) @@ -67,6 +110,6 @@ class EcoforestSensor(SensorEntity, EcoforestEntity): entity_description: EcoforestSensorEntityDescription @property - def native_value(self) -> float | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index b35ff1dedf1..bd0605eab82 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -19,6 +19,39 @@ } }, "entity": { + "sensor": { + "cpu_temperature": { + "name": "CPU temperature" + }, + "gas_temperature": { + "name": "Gas temperature" + }, + "ntc_temperature": { + "name": "NTC probe temperature" + }, + "status": { + "name": "Status", + "state": { + "off": "[%key:common::state::off%]", + "starting": "Starting", + "pre_heating": "Pre-heating", + "on": "[%key:common::state::on%]", + "shutting_down": "Shutting down", + "stand_by": "[%key:common::state::standby%]", + "alarm": "Alarm" + } + }, + "alarm": { + "name": "Alarm", + "state": { + "air_depression": "Air depression", + "pellets": "Pellets", + "cpu_overheating": "CPU overheating", + "unkownn": "Unknown alarm", + "none": "None" + } + } + }, "number": { "power_level": { "name": "Power level" From 1dadfcd52c077c92351f3455b410ac3164800cd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 Sep 2023 11:16:37 +0200 Subject: [PATCH 690/984] Avoid polling in sun sensor entities (#100693) --- homeassistant/components/sun/__init__.py | 6 +++- homeassistant/components/sun/const.py | 3 ++ homeassistant/components/sun/sensor.py | 28 ++++++++++++--- tests/components/sun/test_sensor.py | 46 +++++++++++++++++++++--- 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 5bb105f8123..feb68d76f6a 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, event +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.sun import ( get_astral_location, @@ -24,7 +25,7 @@ from homeassistant.helpers.sun import ( from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED _LOGGER = logging.getLogger(__name__) @@ -285,6 +286,7 @@ class Sun(Entity): if self._update_sun_position_listener: self._update_sun_position_listener() self.update_sun_position() + async_dispatcher_send(self.hass, SIGNAL_EVENTS_CHANGED) # Set timer for the next solar event self._update_events_listener = event.async_track_point_in_utc_time( @@ -312,6 +314,8 @@ class Sun(Entity): ) self.async_write_ha_state() + async_dispatcher_send(self.hass, SIGNAL_POSITION_CHANGED) + # Next update as per the current phase assert self.phase delta = _PHASE_UPDATES[self.phase] diff --git a/homeassistant/components/sun/const.py b/homeassistant/components/sun/const.py index f567c77e62a..245f8ca1d58 100644 --- a/homeassistant/components/sun/const.py +++ b/homeassistant/components/sun/const.py @@ -4,3 +4,6 @@ from typing import Final DOMAIN: Final = "sun" DEFAULT_NAME: Final = "Sun" + +SIGNAL_POSITION_CHANGED = f"{DOMAIN}_position_changed" +SIGNAL_EVENTS_CHANGED = f"{DOMAIN}_events_changed" diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 6eccbc93d37..f83564bbac3 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -16,11 +16,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, EntityCategory from homeassistant.core import HomeAssistant 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.helpers.typing import StateType from . import Sun -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" @@ -30,6 +31,7 @@ class SunEntityDescriptionMixin: """Mixin for required Sun base description keys.""" value_fn: Callable[[Sun], StateType | datetime] + signal: str @dataclass @@ -44,6 +46,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_dawn", icon="mdi:sun-clock", value_fn=lambda data: data.next_dawn, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_dusk", @@ -51,6 +54,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_dusk", icon="mdi:sun-clock", value_fn=lambda data: data.next_dusk, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_midnight", @@ -58,6 +62,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_midnight", icon="mdi:sun-clock", value_fn=lambda data: data.next_midnight, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_noon", @@ -65,6 +70,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_noon", icon="mdi:sun-clock", value_fn=lambda data: data.next_noon, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_rising", @@ -72,6 +78,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_rising", icon="mdi:sun-clock", value_fn=lambda data: data.next_rising, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="next_setting", @@ -79,6 +86,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( translation_key="next_setting", icon="mdi:sun-clock", value_fn=lambda data: data.next_setting, + signal=SIGNAL_EVENTS_CHANGED, ), SunSensorEntityDescription( key="solar_elevation", @@ -88,6 +96,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( value_fn=lambda data: data.solar_elevation, entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, + signal=SIGNAL_POSITION_CHANGED, ), SunSensorEntityDescription( key="solar_azimuth", @@ -97,6 +106,7 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( value_fn=lambda data: data.solar_azimuth, entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, + signal=SIGNAL_POSITION_CHANGED, ), ) @@ -117,6 +127,7 @@ class SunSensor(SensorEntity): """Representation of a Sun Sensor.""" _attr_has_entity_name = True + _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: SunSensorEntityDescription @@ -128,7 +139,6 @@ class SunSensor(SensorEntity): self.entity_id = ENTITY_ID_SENSOR_FORMAT.format(entity_description.key) self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun - self._attr_device_info = DeviceInfo( name="Sun", identifiers={(DOMAIN, entry_id)}, @@ -138,5 +148,15 @@ class SunSensor(SensorEntity): @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" - state = self.entity_description.value_fn(self.sun) - return state + return self.entity_description.value_fn(self.sun) + + async def async_added_to_hass(self) -> None: + """Register signal listener when added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.entity_description.signal, + self.async_write_ha_state, + ) + ) diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 38453569269..6559cc3f7e9 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -3,7 +3,8 @@ from datetime import datetime, timedelta from astral import LocationInfo import astral.sun -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components import sun from homeassistant.const import EntityCategory @@ -13,12 +14,15 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -async def test_setting_rising(hass: HomeAssistant) -> None: +async def test_setting_rising( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, +) -> None: """Test retrieving sun setting and rising.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) - with freeze_time(utc_now): - await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - + freezer.move_to(utc_now) + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) await hass.async_block_till_done() utc_today = utc_now.date() @@ -81,6 +85,9 @@ async def test_setting_rising(hass: HomeAssistant) -> None: break mod += 1 + expected_solar_elevation = astral.sun.elevation(location.observer, utc_now) + expected_solar_azimuth = astral.sun.azimuth(location.observer, utc_now) + state1 = hass.states.get("sensor.sun_next_dawn") state2 = hass.states.get("sensor.sun_next_dusk") state3 = hass.states.get("sensor.sun_next_midnight") @@ -93,6 +100,14 @@ async def test_setting_rising(hass: HomeAssistant) -> None: assert next_noon.replace(microsecond=0) == dt_util.parse_datetime(state4.state) assert next_rising.replace(microsecond=0) == dt_util.parse_datetime(state5.state) assert next_setting.replace(microsecond=0) == dt_util.parse_datetime(state6.state) + solar_elevation_state = hass.states.get("sensor.sun_solar_elevation") + assert float(solar_elevation_state.state) == pytest.approx( + expected_solar_elevation, 0.1 + ) + solar_azimuth_state = hass.states.get("sensor.sun_solar_azimuth") + assert float(solar_azimuth_state.state) == pytest.approx( + expected_solar_azimuth, 0.1 + ) entry_ids = hass.config_entries.async_entries("sun") @@ -102,3 +117,24 @@ async def test_setting_rising(hass: HomeAssistant) -> None: assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dawn" + + freezer.tick(timedelta(hours=24)) + # Block once for Sun to update + await hass.async_block_till_done() + # Block another time for the sensors to update + await hass.async_block_till_done() + + # Make sure all the signals work + assert state1.state != hass.states.get("sensor.sun_next_dawn").state + assert state2.state != hass.states.get("sensor.sun_next_dusk").state + assert state3.state != hass.states.get("sensor.sun_next_midnight").state + assert state4.state != hass.states.get("sensor.sun_next_noon").state + assert state5.state != hass.states.get("sensor.sun_next_rising").state + assert state6.state != hass.states.get("sensor.sun_next_setting").state + assert ( + solar_elevation_state.state + != hass.states.get("sensor.sun_solar_elevation").state + ) + assert ( + solar_azimuth_state.state != hass.states.get("sensor.sun_solar_azimuth").state + ) From 1041610a70f065ee4dcc0a1c9152e6a327d1e70c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Sep 2023 11:22:09 +0200 Subject: [PATCH 691/984] Avoid redundant calls to `async_write_ha_state` in MQTT mqtt alarm_control_panel (#100691) Limit state writes for mqtt alarm_control_panel --- .../components/mqtt/alarm_control_panel.py | 11 ++++-- .../mqtt/test_alarm_control_panel.py | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 4639bd82eb3..2bfaa7d1913 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -42,9 +42,14 @@ from .const import ( CONF_SUPPORTED_FEATURES, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -196,6 +201,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_state"}) def message_received(msg: ReceiveMessage) -> None: """Run when new MQTT message has been received.""" payload = self._value_template(msg.payload) @@ -214,7 +220,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): _LOGGER.warning("Received unexpected payload: %s", msg.payload) return self._attr_state = str(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 35fba9e2a0c..7532319854a 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -62,6 +62,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1232,3 +1233,39 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 384adb1c87184a83fb5e9d6cbe84401f5c993ffc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Sep 2023 11:22:57 +0200 Subject: [PATCH 692/984] Avoid redundant calls to `async_write_ha_state` in MQTT climate & water_heater (#100696) Limit state writes for mqtt climate & water_heater --- homeassistant/components/mqtt/climate.py | 30 ++++++---- homeassistant/components/mqtt/water_heater.py | 10 +++- tests/components/mqtt/test_climate.py | 58 +++++++++++++++++++ tests/components/mqtt/test_water_heater.py | 41 +++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index d5bda57c2b3..dfd5d70dca6 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -82,7 +82,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -90,7 +95,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -478,11 +483,9 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): return if payload == PAYLOAD_NONE: setattr(self, attr, None) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: setattr(self, attr, float(payload)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) except ValueError: _LOGGER.error("Could not parse %s from %s", template_name, payload) @@ -493,6 +496,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_temperature"}) def handle_current_temperature_received(msg: ReceiveMessage) -> None: """Handle current temperature coming via MQTT.""" self.handle_climate_attribute_received( @@ -505,6 +509,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature"}) def handle_target_temperature_received(msg: ReceiveMessage) -> None: """Handle target temperature coming via MQTT.""" self.handle_climate_attribute_received( @@ -517,6 +522,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature_low"}) def handle_temperature_low_received(msg: ReceiveMessage) -> None: """Handle target temperature low coming via MQTT.""" self.handle_climate_attribute_received( @@ -529,6 +535,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_temperature_high"}) def handle_temperature_high_received(msg: ReceiveMessage) -> None: """Handle target temperature high coming via MQTT.""" self.handle_climate_attribute_received( @@ -789,6 +796,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_hvac_action"}) def handle_action_received(msg: ReceiveMessage) -> None: """Handle receiving action via MQTT.""" payload = self.render_template(msg, CONF_ACTION_TEMPLATE) @@ -808,12 +816,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): payload, ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_humidity"}) def handle_current_humidity_received(msg: ReceiveMessage) -> None: """Handle current humidity coming via MQTT.""" self.handle_climate_attribute_received( @@ -825,6 +833,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ) @callback + @write_state_on_attr_change(self, {"_attr_target_humidity"}) @log_messages(self.hass, self.entity_id) def handle_target_humidity_received(msg: ReceiveMessage) -> None: """Handle target humidity coming via MQTT.""" @@ -848,10 +857,10 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _LOGGER.error("Invalid %s mode: %s", mode_list, payload) else: setattr(self, attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_hvac_mode"}) def handle_current_mode_received(msg: ReceiveMessage) -> None: """Handle receiving mode via MQTT.""" handle_mode_received( @@ -864,6 +873,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_fan_mode"}) def handle_fan_mode_received(msg: ReceiveMessage) -> None: """Handle receiving fan mode via MQTT.""" handle_mode_received( @@ -879,6 +889,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_swing_mode"}) def handle_swing_mode_received(msg: ReceiveMessage) -> None: """Handle receiving swing mode via MQTT.""" handle_mode_received( @@ -913,13 +924,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): else: _LOGGER.error("Invalid %s mode: %s", attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 # Support will be removed in HA Core 2024.3 @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_aux_heat"}) def handle_aux_mode_received(msg: ReceiveMessage) -> None: """Handle receiving aux mode via MQTT.""" handle_onoff_mode_received( @@ -930,12 +940,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_preset_mode"}) def handle_preset_mode_received(msg: ReceiveMessage) -> None: """Handle receiving preset mode via MQTT.""" preset_mode = self.render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) if preset_mode in [PRESET_NONE, PAYLOAD_NONE]: self._attr_preset_mode = PRESET_NONE - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) @@ -953,8 +963,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): else: self._attr_preset_mode = str(preset_mode) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self.add_subscription( topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received ) diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 08b9d36d850..f35e7f8b0ea 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -65,9 +65,13 @@ from .const import ( DEFAULT_OPTIMISTIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -292,10 +296,10 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _LOGGER.error("Invalid %s mode: %s", mode_list, payload) else: setattr(self, attr, payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_operation"}) def handle_current_mode_received(msg: ReceiveMessage) -> None: """Handle receiving operation mode via MQTT.""" handle_mode_received( diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9e0363b3611..9c0adbe2adf 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -58,6 +58,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -2555,3 +2556,60 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "action_topic": "action-topic", + "fan_mode_state_topic": "fan-mode-state-topic", + "mode_state_topic": "mode-state-topic", + "current_humidity_topic": "current-humidity-topic", + "current_temperature_topic": "current-temperature-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_modes": ["eco", "away"], + "swing_mode_state_topic": "swing-mode-state-topic", + "target_humidity_state_topic": "target-humidity-state-topic", + "temperature_high_state_topic": "temperature-high-state-topic", + "temperature_low_state_topic": "temperature-low-state-topic", + "temperature_state_topic": "temperature-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("action-topic", "cooling", "heating"), + ("fan-mode-state-topic", "low", "medium"), + ("mode-state-topic", "cool", "heat"), + ("current-humidity-topic", "45", "46"), + ("current-temperature-topic", "18.0", "18.1"), + ("preset-mode-state-topic", "eco", "away"), + ("swing-mode-state-topic", "on", "off"), + ("target-humidity-state-topic", "45", "50"), + ("temperature-state-topic", "18", "19"), + ("temperature-low-state-topic", "18", "19"), + ("temperature-high-state-topic", "18", "19"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index 245af5c6918..60c3af63bf4 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -52,6 +52,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1220,3 +1221,43 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "mode_state_topic": "mode-state-topic", + "current_temperature_topic": "current-temperature-topic", + "temperature_state_topic": "temperature-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("mode-state-topic", "gas", "electric"), + ("current-temperature-topic", "18.0", "18.1"), + ("temperature-state-topic", "18", "19"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From a66ad39c4ec9aae1a650430929e29c49bb6d236d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Sep 2023 12:09:37 +0200 Subject: [PATCH 693/984] Assign color_mode for mqtt light as ColorMode (#100709) --- homeassistant/components/mqtt/light/schema_basic.py | 2 +- tests/components/mqtt/test_light.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 34b4a567ba5..03f78b8c43f 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -557,7 +557,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _LOGGER.debug("Ignoring empty color mode message from '%s'", msg.topic) return - self._attr_color_mode = str(payload) + self._attr_color_mode = ColorMode(str(payload)) get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_COLOR_MODE_STATE_TOPIC, color_mode_received) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 08def9a923e..ba0b21b5ceb 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -3175,9 +3175,9 @@ async def test_reloadable( ("state_topic", "ON", None, "on", None), ( "color_mode_state_topic", - "200", + "rgb", "color_mode", - "200", + "rgb", ("state_topic", "ON"), ), ("color_temp_state_topic", "200", "color_temp", 200, ("state_topic", "ON")), From 794736b5031785bb0569b9a4fdc98e9e8bc06e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Janu=C3=A1rio?= Date: Fri, 22 Sep 2023 11:20:48 +0100 Subject: [PATCH 694/984] Add switch platform to ecoforest integration (#100708) * add switch platform to ecoforest integration * ignore switch.py from coverage * rename mixin Co-authored-by: Joost Lekkerkerker * update translations Co-authored-by: Joost Lekkerkerker * remove translation key Co-authored-by: Joost Lekkerkerker * move attr name to description Co-authored-by: Joost Lekkerkerker * fix broken change * use entity description action * use lambda with awaitable --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + .../components/ecoforest/__init__.py | 2 +- homeassistant/components/ecoforest/switch.py | 78 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ecoforest/switch.py diff --git a/.coveragerc b/.coveragerc index 2eebbee3a3e..da0b312ac83 100644 --- a/.coveragerc +++ b/.coveragerc @@ -264,6 +264,7 @@ omit = homeassistant/components/ecoforest/entity.py homeassistant/components/ecoforest/number.py homeassistant/components/ecoforest/sensor.py + homeassistant/components/ecoforest/switch.py homeassistant/components/econet/__init__.py homeassistant/components/econet/binary_sensor.py homeassistant/components/econet/climate.py diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py index 1e2667256b1..205dfe67deb 100644 --- a/homeassistant/components/ecoforest/__init__.py +++ b/homeassistant/components/ecoforest/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import EcoforestCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py new file mode 100644 index 00000000000..32341ff5d61 --- /dev/null +++ b/homeassistant/components/ecoforest/switch.py @@ -0,0 +1,78 @@ +"""Switch platform for Ecoforest.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pyecoforest.api import EcoforestApi +from pyecoforest.models.device import Device + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + + +@dataclass +class EcoforestSwitchRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], bool] + switch_fn: Callable[[EcoforestApi, bool], Awaitable[Device]] + + +@dataclass +class EcoforestSwitchEntityDescription( + SwitchEntityDescription, EcoforestSwitchRequiredKeysMixin +): + """Describes an Ecoforest switch entity.""" + + +SWITCH_TYPES: tuple[EcoforestSwitchEntityDescription, ...] = ( + EcoforestSwitchEntityDescription( + key="status", + name=None, + value_fn=lambda data: data.on, + switch_fn=lambda api, status: api.turn(status), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Ecoforest switch platform.""" + coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + EcoforestSwitchEntity(coordinator, description) for description in SWITCH_TYPES + ] + + async_add_entities(entities) + + +class EcoforestSwitchEntity(EcoforestEntity, SwitchEntity): + """Representation of an Ecoforest switch entity.""" + + entity_description: EcoforestSwitchEntityDescription + + @property + def is_on(self) -> bool: + """Return the state of the ecoforest device.""" + return self.entity_description.value_fn(self.data) + + async def async_turn_on(self): + """Turn on the ecoforest device.""" + await self.entity_description.switch_fn(self.coordinator.api, True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self): + """Turn off the ecoforest device.""" + await self.entity_description.switch_fn(self.coordinator.api, False) + await self.coordinator.async_request_refresh() From d30a5f4d544c4304f18176cce5d590d43e43e2ec Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 22 Sep 2023 12:45:22 +0200 Subject: [PATCH 695/984] Move samsung tv device class outside of constructor (#100712) --- homeassistant/components/samsungtv/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 06783314b4c..87174b13dd6 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -72,6 +72,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Representation of a Samsung TV.""" _attr_source_list: list[str] + _attr_device_class = MediaPlayerDeviceClass.TV def __init__( self, @@ -90,7 +91,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): self._playing: bool = True self._attr_is_volume_muted: bool = False - self._attr_device_class = MediaPlayerDeviceClass.TV self._attr_source_list = list(SOURCES) self._app_list: dict[str, str] | None = None self._app_list_event: asyncio.Event = asyncio.Event() From 87ae5add8a92c5fa55748ff1591c3f6a656cc5fe Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Sep 2023 13:31:29 +0200 Subject: [PATCH 696/984] Fix mqtt light rgbww update without state topic (#100707) * Fix mqtt light rgbww update without state topic * Add @callback decprator and correct mired conv --- .../components/mqtt/light/schema_basic.py | 18 +- tests/components/mqtt/test_light.py | 171 ++++++++++++++++++ 2 files changed, 188 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 03f78b8c43f..c12d8719d7a 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -463,6 +463,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): add_topic(CONF_BRIGHTNESS_STATE_TOPIC, brightness_received) + @callback def _rgbx_received( msg: ReceiveMessage, template: str, @@ -533,11 +534,26 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @log_messages(self.hass, self.entity_id) def rgbww_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for RGBWW.""" + + @callback + def _converter( + r: int, g: int, b: int, cw: int, ww: int + ) -> tuple[int, int, int]: + min_kelvin = color_util.color_temperature_mired_to_kelvin( + self.max_mireds + ) + max_kelvin = color_util.color_temperature_mired_to_kelvin( + self.min_mireds + ) + return color_util.color_rgbww_to_rgb( + r, g, b, cw, ww, min_kelvin, max_kelvin + ) + rgbww = _rgbx_received( msg, CONF_RGBWW_VALUE_TEMPLATE, ColorMode.RGBWW, - color_util.color_rgbww_to_rgb, + _converter, ) if rgbww is None: return diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index ba0b21b5ceb..0199ee19772 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -198,6 +198,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -441,6 +442,176 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "optimistic": True, + "brightness_command_topic": "test_light_rgb/brightness/set", + "color_mode_state_topic": "color-mode-state-topic", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgb_state_topic": "rgb-state-topic", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbw_state_topic": "rgbw-state-topic", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "rgbww_state_topic": "rgbww-state-topic", + }, + ), + ) + ], +) +async def test_received_rgbx_values_set_state_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the state is set correctly when an rgbx update is received.""" + await mqtt_mock_entry() + state = hass.states.get("light.test") + assert state and state.state is not None + async_fire_mqtt_message(hass, "test-topic", "ON") + ## Test rgb processing + async_fire_mqtt_message(hass, "rgb-state-topic", "255,255,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgb" + assert state.attributes["rgb_color"] == (255, 255, 255) + + # Only update color mode + async_fire_mqtt_message(hass, "color-mode-state-topic", "rgbww") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbww" + + # Resending same rgb value should restore color mode + async_fire_mqtt_message(hass, "rgb-state-topic", "255,255,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgb" + assert state.attributes["rgb_color"] == (255, 255, 255) + + # Only update brightness + await common.async_turn_on(hass, "light.test", brightness=128) + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 128 + assert state.attributes["color_mode"] == "rgb" + assert state.attributes["rgb_color"] == (255, 255, 255) + + # Resending same rgb value should restore brightness + async_fire_mqtt_message(hass, "rgb-state-topic", "255,255,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgb" + assert state.attributes["rgb_color"] == (255, 255, 255) + + # Only change rgb value + async_fire_mqtt_message(hass, "rgb-state-topic", "255,255,0") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgb" + assert state.attributes["rgb_color"] == (255, 255, 0) + + ## Test rgbw processing + async_fire_mqtt_message(hass, "rgbw-state-topic", "255,255,255,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbw" + assert state.attributes["rgbw_color"] == (255, 255, 255, 255) + + # Only update color mode + async_fire_mqtt_message(hass, "color-mode-state-topic", "rgb") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgb" + + # Resending same rgbw value should restore color mode + async_fire_mqtt_message(hass, "rgbw-state-topic", "255,255,255,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbw" + assert state.attributes["rgbw_color"] == (255, 255, 255, 255) + + # Only update brightness + await common.async_turn_on(hass, "light.test", brightness=128) + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 128 + assert state.attributes["color_mode"] == "rgbw" + assert state.attributes["rgbw_color"] == (255, 255, 255, 255) + + # Resending same rgbw value should restore brightness + async_fire_mqtt_message(hass, "rgbw-state-topic", "255,255,255,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbw" + assert state.attributes["rgbw_color"] == (255, 255, 255, 255) + + # Only change rgbw value + async_fire_mqtt_message(hass, "rgbw-state-topic", "255,255,128,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbw" + assert state.attributes["rgbw_color"] == (255, 255, 128, 255) + + ## Test rgbww processing + async_fire_mqtt_message(hass, "rgbww-state-topic", "255,255,255,32,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbww" + assert state.attributes["rgbww_color"] == (255, 255, 255, 32, 255) + + # Only update color mode + async_fire_mqtt_message(hass, "color-mode-state-topic", "rgb") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgb" + + # Resending same rgbw value should restore color mode + async_fire_mqtt_message(hass, "rgbww-state-topic", "255,255,255,32,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbww" + assert state.attributes["rgbww_color"] == (255, 255, 255, 32, 255) + + # Only update brightness + await common.async_turn_on(hass, "light.test", brightness=128) + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 128 + assert state.attributes["color_mode"] == "rgbww" + assert state.attributes["rgbww_color"] == (255, 255, 255, 32, 255) + + # Resending same rgbww value should restore brightness + async_fire_mqtt_message(hass, "rgbww-state-topic", "255,255,255,32,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbww" + assert state.attributes["rgbww_color"] == (255, 255, 255, 32, 255) + + # Only change rgbww value + async_fire_mqtt_message(hass, "rgbww-state-topic", "255,255,128,32,255") + await hass.async_block_till_done() + state = hass.states.get("light.test") + assert state.attributes["brightness"] == 255 + assert state.attributes["color_mode"] == "rgbww" + assert state.attributes["rgbww_color"] == (255, 255, 128, 32, 255) + + @pytest.mark.parametrize( "hass_config", [ From 5b422daf36c06636e5d5b00f5237b65e3b66fcfd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Sep 2023 13:32:30 +0200 Subject: [PATCH 697/984] Avoid redundant calls to `async_write_ha_state` in MQTT light (#100690) * Limit state writes for mqtt light * Additional tests and review follow up --- .../components/mqtt/light/schema_basic.py | 31 +++--- .../components/mqtt/light/schema_json.py | 21 ++++- .../components/mqtt/light/schema_template.py | 16 +++- tests/components/mqtt/test_light.py | 57 +++++++++++ tests/components/mqtt/test_light_json.py | 94 +++++++++++++++++++ tests/components/mqtt/test_light_template.py | 66 +++++++++++++ 6 files changed, 264 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index c12d8719d7a..ab8d9921161 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -55,7 +55,7 @@ from ..const import ( PAYLOAD_NONE, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ( MessageCallbackType, MqttCommandTemplate, @@ -66,7 +66,7 @@ from ..models import ( ReceivePayloadType, TemplateVarsType, ) -from ..util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from ..util import valid_publish_topic, valid_subscribe_topic from .schema import MQTT_LIGHT_SCHEMA_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -415,6 +415,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload = self._value_templates[CONF_STATE_VALUE_TEMPLATE]( @@ -430,7 +431,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self._attr_is_on = False elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._topic[CONF_STATE_TOPIC] is not None: topics[CONF_STATE_TOPIC] = { @@ -442,6 +442,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_brightness"}) def brightness_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for the brightness.""" payload = self._value_templates[CONF_BRIGHTNESS_VALUE_TEMPLATE]( @@ -459,8 +460,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): percent_bright = device_value / self._config[CONF_BRIGHTNESS_SCALE] self._attr_brightness = min(round(percent_bright * 255), 255) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - add_topic(CONF_BRIGHTNESS_STATE_TOPIC, brightness_received) @callback @@ -501,6 +500,9 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_brightness", "_attr_color_mode", "_attr_rgb_color"} + ) def rgb_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for RGB.""" rgb = _rgbx_received( @@ -509,12 +511,14 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if rgb is None: return self._attr_rgb_color = cast(tuple[int, int, int], rgb) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_RGB_STATE_TOPIC, rgb_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_brightness", "_attr_color_mode", "_attr_rgbw_color"} + ) def rgbw_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for RGBW.""" rgbw = _rgbx_received( @@ -526,12 +530,14 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if rgbw is None: return self._attr_rgbw_color = cast(tuple[int, int, int, int], rgbw) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_RGBW_STATE_TOPIC, rgbw_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_brightness", "_attr_color_mode", "_attr_rgbww_color"} + ) def rgbww_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for RGBWW.""" @@ -558,12 +564,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if rgbww is None: return self._attr_rgbww_color = cast(tuple[int, int, int, int, int], rgbww) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_RGBWW_STATE_TOPIC, rgbww_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_color_mode"}) def color_mode_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for color mode.""" payload = self._value_templates[CONF_COLOR_MODE_VALUE_TEMPLATE]( @@ -574,12 +580,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): return self._attr_color_mode = ColorMode(str(payload)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_COLOR_MODE_STATE_TOPIC, color_mode_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_color_temp"}) def color_temp_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for color temperature.""" payload = self._value_templates[CONF_COLOR_TEMP_VALUE_TEMPLATE]( @@ -592,12 +598,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_color_temp = int(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_COLOR_TEMP_STATE_TOPIC, color_temp_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_effect"}) def effect_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for effect.""" payload = self._value_templates[CONF_EFFECT_VALUE_TEMPLATE]( @@ -608,12 +614,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): return self._attr_effect = str(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_EFFECT_STATE_TOPIC, effect_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_hs_color"}) def hs_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for hs color.""" payload = self._value_templates[CONF_HS_VALUE_TEMPLATE]( @@ -627,7 +633,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.HS self._attr_hs_color = cast(tuple[float, float], hs_color) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) except ValueError: _LOGGER.warning("Failed to parse hs state update: '%s'", payload) @@ -635,6 +640,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_xy_color"}) def xy_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages for xy color.""" payload = self._value_templates[CONF_XY_VALUE_TEMPLATE]( @@ -648,7 +654,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._optimistic_color_mode: self._attr_color_mode = ColorMode.XY self._attr_xy_color = cast(tuple[float, float], xy_color) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_topic(CONF_XY_STATE_TOPIC, xy_received) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 11574b88798..ee7e78b0028 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -63,9 +63,9 @@ from ..const import ( CONF_STATE_TOPIC, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ReceiveMessage -from ..util import get_mqtt_data, valid_subscribe_topic +from ..util import valid_subscribe_topic from .schema import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( CONF_BRIGHTNESS_SCALE, @@ -347,6 +347,21 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_brightness", + "_attr_color_temp", + "_attr_effect", + "_attr_hs_color", + "_attr_is_on", + "_attr_rgb_color", + "_attr_rgbw_color", + "_attr_rgbww_color", + "_attr_xy_color", + "color_mode", + }, + ) def state_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" values = json_loads_object(msg.payload) @@ -419,8 +434,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): with suppress(KeyError): self._attr_effect = cast(str, values["effect"]) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_STATE_TOPIC] is not None: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index e811c45fc67..ecbcdcd18d7 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -46,7 +46,7 @@ from ..const import ( PAYLOAD_NONE, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ( MqttCommandTemplate, MqttValueTemplate, @@ -54,7 +54,6 @@ from ..models import ( ReceiveMessage, ReceivePayloadType, ) -from ..util import get_mqtt_data from .schema import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import MQTT_LIGHT_ATTRIBUTES_BLOCKED @@ -215,6 +214,17 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_brightness", + "_attr_color_mode", + "_attr_color_temp", + "_attr_effect", + "_attr_hs_color", + "_attr_is_on", + }, + ) def state_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) @@ -283,8 +293,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): else: _LOGGER.warning("Unsupported effect value received") - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topics[CONF_STATE_TOPIC] is not None: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 0199ee19772..58d37943403 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -221,6 +221,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -3635,3 +3636,59 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + "state_value_template": "{{ value_json.state }}", + "brightness_state_topic": "brightness-state-topic", + "color_mode_state_topic": "color-mode-state-topic", + "color_temp_state_topic": "color-temp-state-topic", + "effect_state_topic": "effect-state-topic", + "effect_list": ["effect1", "effect2"], + "hs_state_topic": "hs-state-topic", + "xy_state_topic": "xy-state-topic", + "rgb_state_topic": "rgb-state-topic", + "rgbw_state_topic": "rgbw-state-topic", + "rgbww_state_topic": "rgbww-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"ON"}', '{"state":"OFF"}'), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("brightness-state-topic", "50", "100"), + ("color-mode-state-topic", "rgb", "color_temp"), + ("color-temp-state-topic", "800", "200"), + ("effect-state-topic", "effect1", "effect2"), + ("hs-state-topic", "210,50", "200,50"), + ("xy-state-topic", "128,128", "96,96"), + ("rgb-state-topic", "128,128,128", "128,128,64"), + ("rgbw-state-topic", "128,128,128,255", "128,128,128,128"), + ("rgbww-state-topic", "128,128,128,32,255", "128,128,128,64,255"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7ff4ccbab85..3b44f86460f 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -124,6 +124,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -2453,3 +2454,96 @@ async def test_setup_manual_entity_from_yaml( await mqtt_mock_entry() platform = light.DOMAIN assert hass.states.get(f"{platform}.test") + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "color_mode": True, + "effect": True, + "supported_color_modes": [ + "color_temp", + "hs", + "xy", + "rgb", + "rgbw", + "rgbww", + "white", + ], + "effect_list": ["effect1", "effect2"], + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"ON"}', '{"state":"OFF"}'), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ( + "test-topic", + '{"state":"ON","effect":"effect1"}', + '{"state":"ON","effect":"effect2"}', + ), + ( + "test-topic", + '{"state":"ON","brightness":255}', + '{"state":"ON","brightness":96}', + ), + ( + "test-topic", + '{"state":"ON","brightness":96}', + '{"state":"ON","color_mode":"white","brightness":96}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"color_temp", "color_temp": 200}', + '{"state":"ON","color_mode":"color_temp", "color_temp": 2400}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"hs", "color": {"h":24.0,"s":100.0}}', + '{"state":"ON","color_mode":"hs", "color": {"h":24.0,"s":90.0}}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"xy","color": {"x":0.14,"y":0.131}}', + '{"state":"ON","color_mode":"xy","color": {"x":0.16,"y": 0.100}}', + ), + ( + "test-topic", + '{"state":"ON","brightness":255,"color_mode":"rgb","color":{"r":128,"g":128,"b":255}}', + '{"state":"ON","brightness":255,"color_mode":"rgb","color": {"r":255,"g":128,"b":255}}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"rgbw","color":{"r":128,"g":128,"b":255,"w":128}}', + '{"state":"ON","color_mode":"rgbw","color": {"r":128,"g":128,"b":255,"w":255}}', + ), + ( + "test-topic", + '{"state":"ON","color_mode":"rgbww","color":{"r":128,"g":128,"b":255,"c":32,"w":128}}', + '{"state":"ON","color_mode":"rgbww","color": {"r":128,"g":128,"b":255,"c":16,"w":128}}', + ), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 0583a1176b6..f9f355025e9 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -46,6 +46,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -68,6 +69,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1378,3 +1380,67 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + "state_template": "{{ value_json.state }}", + "brightness_template": "{{ value_json.brightness }}", + "color_temp_template": "{{ value_json.color_temp }}", + "effect_template": "{{ value_json.effect }}", + "red_template": "{{ value_json.r }}", + "green_template": "{{ value_json.g }}", + "blue_template": "{{ value_json.b }}", + "effect_list": ["effect1", "effect2"], + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"on"}', '{"state":"off"}'), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ( + "test-topic", + '{"state":"on", "brightness":50}', + '{"state":"on", "brightness":100}', + ), + ( + "test-topic", + '{"state":"on", "brightness":50,"color_temp":200}', + '{"state":"on", "brightness":50,"color_temp":1600}', + ), + ( + "test-topic", + '{"state":"on", "r":128, "g":128, "b":128}', + '{"state":"on", "r":128, "g":128, "b":255}', + ), + ( + "test-topic", + '{"state":"on", "effect":"effect1"}', + '{"state":"on", "effect":"effect2"}', + ), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 41331620538ae408638dd66fe91ac1acfad23bee Mon Sep 17 00:00:00 2001 From: Daniel Weeber Date: Fri, 22 Sep 2023 13:39:32 +0200 Subject: [PATCH 698/984] Add device class to denonavr (#100711) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/denonavr/media_player.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index c3dfbeb1011..51ede0d65b4 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -19,6 +19,7 @@ from denonavr.exceptions import ( import voluptuous as vol from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -218,6 +219,7 @@ class DenonDevice(MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None + _attr_device_class = MediaPlayerDeviceClass.RECEIVER def __init__( self, @@ -238,7 +240,6 @@ class DenonDevice(MediaPlayerEntity): name=receiver.name, ) self._attr_sound_mode_list = receiver.sound_mode_list - self._receiver = receiver self._update_audyssey = update_audyssey From 4c65c92fb0e25a7877fa73ba7010623b1e54d597 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Sep 2023 13:41:31 +0200 Subject: [PATCH 699/984] Use shorthand attrs for MQTT cover (#100710) * User shorthand attrs for MQTT cover * Cleanup constructor * Cleanup constructor --- homeassistant/components/mqtt/cover.py | 118 ++++++++----------------- 1 file changed, 38 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 3044e2d6396..7423094d209 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -235,26 +235,12 @@ async def _async_setup_entity( class MqttCover(MqttEntity, CoverEntity): """Representation of a cover that can be controlled using MQTT.""" + _attr_is_closed: bool | None = None + _attributes_extra_blocked: frozenset[str] = MQTT_COVER_ATTRIBUTES_BLOCKED _default_name = DEFAULT_NAME _entity_id_format: str = cover.ENTITY_ID_FORMAT - _attributes_extra_blocked: frozenset[str] = MQTT_COVER_ATTRIBUTES_BLOCKED - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the cover.""" - self._position: int | None = None - self._state: str | None = None - - self._optimistic: bool | None = None - self._tilt_value: int | None = None - self._tilt_optimistic: bool | None = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + _optimistic: bool + _tilt_optimistic: bool @staticmethod def config_schema() -> vol.Schema: @@ -287,21 +273,17 @@ class MqttCover(MqttEntity, CoverEntity): and config.get(CONF_TILT_STATUS_TOPIC) is None ) - if config[CONF_OPTIMISTIC] or ( + self._optimistic = config[CONF_OPTIMISTIC] or ( (no_position or optimistic_position) and (no_state or optimistic_state) and (no_tilt or optimistic_tilt) - ): - # Force into optimistic mode. - self._optimistic = True - self._attr_assumed_state = bool(self._optimistic) + ) + self._attr_assumed_state = self._optimistic - if ( + self._tilt_optimistic = ( config[CONF_TILT_STATE_OPTIMISTIC] or config.get(CONF_TILT_STATUS_TOPIC) is None - ): - # Force into optimistic tilt mode. - self._tilt_optimistic = True + ) template_config_attributes = { "position_open": self._config[CONF_POSITION_OPEN], @@ -354,6 +336,13 @@ class MqttCover(MqttEntity, CoverEntity): self._attr_supported_features = supported_features + @callback + def _update_state(self, state: str) -> None: + """Update the cover state.""" + self._attr_is_closed = state == STATE_CLOSED + self._attr_is_opening = state == STATE_OPENING + self._attr_is_closing = state == STATE_CLOSING + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics = {} @@ -380,25 +369,24 @@ class MqttCover(MqttEntity, CoverEntity): _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) return + state: str if payload == self._config[CONF_STATE_STOPPED]: if self._config.get(CONF_GET_POSITION_TOPIC) is not None: - self._state = ( + state = ( STATE_CLOSED - if self._position == DEFAULT_POSITION_CLOSED + if self._attr_current_cover_position == DEFAULT_POSITION_CLOSED else STATE_OPEN ) else: - self._state = ( - STATE_CLOSED if self._state == STATE_CLOSING else STATE_OPEN - ) + state = STATE_CLOSED if self.state == STATE_CLOSING else STATE_OPEN elif payload == self._config[CONF_STATE_OPENING]: - self._state = STATE_OPENING + state = STATE_OPENING elif payload == self._config[CONF_STATE_CLOSING]: - self._state = STATE_CLOSING + state = STATE_CLOSING elif payload == self._config[CONF_STATE_OPEN]: - self._state = STATE_OPEN + state = STATE_OPEN elif payload == self._config[CONF_STATE_CLOSED]: - self._state = STATE_CLOSED + state = STATE_CLOSED else: _LOGGER.warning( ( @@ -408,6 +396,7 @@ class MqttCover(MqttEntity, CoverEntity): payload, ) return + self._update_state(state) get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @@ -447,9 +436,9 @@ class MqttCover(MqttEntity, CoverEntity): _LOGGER.warning("Payload '%s' is not numeric", payload) return - self._position = percentage_payload + self._attr_current_cover_position = percentage_payload if self._config.get(CONF_STATE_TOPIC) is None: - self._state = ( + self._update_state( STATE_CLOSED if percentage_payload == DEFAULT_POSITION_CLOSED else STATE_OPEN @@ -489,37 +478,6 @@ class MqttCover(MqttEntity, CoverEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def is_closed(self) -> bool | None: - """Return true if the cover is closed or None if the status is unknown.""" - if self._state is None: - return None - - return self._state == STATE_CLOSED - - @property - def is_opening(self) -> bool: - """Return true if the cover is actively opening.""" - return self._state == STATE_OPENING - - @property - def is_closing(self) -> bool: - """Return true if the cover is actively closing.""" - return self._state == STATE_CLOSING - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._position - - @property - def current_cover_tilt_position(self) -> int | None: - """Return current position of cover tilt.""" - return self._tilt_value - async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up. @@ -534,9 +492,9 @@ class MqttCover(MqttEntity, CoverEntity): ) if self._optimistic: # Optimistically assume that cover has changed state. - self._state = STATE_OPEN + self._update_state(STATE_OPEN) if self._config.get(CONF_GET_POSITION_TOPIC): - self._position = self.find_percentage_in_range( + self._attr_current_cover_position = self.find_percentage_in_range( self._config[CONF_POSITION_OPEN], COVER_PAYLOAD ) self.async_write_ha_state() @@ -555,9 +513,9 @@ class MqttCover(MqttEntity, CoverEntity): ) if self._optimistic: # Optimistically assume that cover has changed state. - self._state = STATE_CLOSED + self._update_state(STATE_CLOSED) if self._config.get(CONF_GET_POSITION_TOPIC): - self._position = self.find_percentage_in_range( + self._attr_current_cover_position = self.find_percentage_in_range( self._config[CONF_POSITION_CLOSED], COVER_PAYLOAD ) self.async_write_ha_state() @@ -595,7 +553,7 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._tilt_value = self.find_percentage_in_range( + self._attr_current_cover_tilt_position = self.find_percentage_in_range( float(self._config[CONF_TILT_OPEN_POSITION]) ) self.async_write_ha_state() @@ -622,7 +580,7 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._tilt_value = self.find_percentage_in_range( + self._attr_current_cover_tilt_position = self.find_percentage_in_range( float(self._config[CONF_TILT_CLOSED_POSITION]) ) self.async_write_ha_state() @@ -653,7 +611,7 @@ class MqttCover(MqttEntity, CoverEntity): ) if self._tilt_optimistic: _LOGGER.debug("Set tilt value optimistic") - self._tilt_value = percentage_tilt + self._attr_current_cover_tilt_position = percentage_tilt self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: @@ -679,12 +637,12 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._optimistic: - self._state = ( + self._update_state( STATE_CLOSED if percentage_position == self._config[CONF_POSITION_CLOSED] else STATE_OPEN ) - self._position = percentage_position + self._attr_current_cover_position = percentage_position self.async_write_ha_state() async def async_toggle_tilt(self, **kwargs: Any) -> None: @@ -696,7 +654,7 @@ class MqttCover(MqttEntity, CoverEntity): def is_tilt_closed(self) -> bool: """Return if the cover is tilted closed.""" - return self._tilt_value == self.find_percentage_in_range( + return self._attr_current_cover_tilt_position == self.find_percentage_in_range( float(self._config[CONF_TILT_CLOSED_POSITION]) ) @@ -762,7 +720,7 @@ class MqttCover(MqttEntity, CoverEntity): <= self._config[CONF_TILT_MIN] ): level = self.find_percentage_in_range(payload) - self._tilt_value = level + self._attr_current_cover_tilt_position = level get_mqtt_data(self.hass).state_write_requests.write_state_request(self) else: _LOGGER.warning( From 8474c25cf17b362884643a292d31e27abec596ce Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 22 Sep 2023 14:20:34 +0200 Subject: [PATCH 700/984] Reolink remove unneeded str() (#100718) --- homeassistant/components/reolink/config_flow.py | 2 +- homeassistant/components/reolink/host.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e86da1f23a7..59fbdc22747 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -122,7 +122,7 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "Reolink DHCP reported new IP '%s', " "but got error '%s' trying to connect, so sticking to IP '%s'", discovery_info.ip, - str(err), + err, existing_entry.data[CONF_HOST], ) raise AbortFlow("already_configured") from err diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 2487013b032..d470711267d 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -322,7 +322,7 @@ class ReolinkHost: "Reolink error while unsubscribing from host %s:%s: %s", self._api.host, self._api.port, - str(err), + err, ) try: @@ -332,7 +332,7 @@ class ReolinkHost: "Reolink error while logging out for host %s:%s: %s", self._api.host, self._api.port, - str(err), + err, ) async def _async_start_long_polling(self, initial=False): @@ -349,7 +349,7 @@ class ReolinkHost: _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, - str(err), + err, ) except ReolinkError as err: # make sure the long_poll_task is always created to try again later @@ -358,7 +358,7 @@ class ReolinkHost: _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, - str(err), + err, ) else: self._lost_subscription = False @@ -428,7 +428,7 @@ class ReolinkHost: _LOGGER.error( "Reolink %s event subscription lost: %s", self._api.nvr_name, - str(err), + err, ) else: self._lost_subscription = False @@ -568,7 +568,7 @@ class ReolinkHost: "Reolink error while polling motion state for host %s:%s: %s", self._api.host, self._api.port, - str(err), + err, ) finally: # schedule next poll From 2a49b6ca7e8d027c02d46336d0ca24f1b5ab30af Mon Sep 17 00:00:00 2001 From: Olen Date: Fri, 22 Sep 2023 14:42:17 +0200 Subject: [PATCH 701/984] Add more august actions (#100667) --- homeassistant/components/august/const.py | 4 + homeassistant/components/august/manifest.json | 2 +- homeassistant/components/august/sensor.py | 20 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../get_activity.lock_from_manual.json | 39 +++++ .../get_activity.unlock_from_manual.json | 39 +++++ .../get_activity.unlock_from_tag.json | 39 +++++ tests/components/august/test_sensor.py | 136 +++++++++++++++++- 9 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 tests/components/august/fixtures/get_activity.lock_from_manual.json create mode 100644 tests/components/august/fixtures/get_activity.unlock_from_manual.json create mode 100644 tests/components/august/fixtures/get_activity.unlock_from_tag.json diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 752499e29e2..b97890d09b6 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -26,12 +26,16 @@ DOMAIN = "august" OPERATION_METHOD_AUTORELOCK = "autorelock" OPERATION_METHOD_REMOTE = "remote" OPERATION_METHOD_KEYPAD = "keypad" +OPERATION_METHOD_MANUAL = "manual" +OPERATION_METHOD_TAG = "tag" OPERATION_METHOD_MOBILE_DEVICE = "mobile" ATTR_OPERATION_AUTORELOCK = "autorelock" ATTR_OPERATION_METHOD = "method" ATTR_OPERATION_REMOTE = "remote" ATTR_OPERATION_KEYPAD = "keypad" +ATTR_OPERATION_MANUAL = "manual" +ATTR_OPERATION_TAG = "tag" # Limit battery, online, and hardware updates to hourly # in order to reduce the number of api requests and diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index c5a0da71136..2fe7d62ac3d 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.9.0", "yalexs-ble==2.3.0"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.0"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 12ed3a88558..75e8cd8984c 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -33,13 +33,17 @@ from . import AugustData from .const import ( ATTR_OPERATION_AUTORELOCK, ATTR_OPERATION_KEYPAD, + ATTR_OPERATION_MANUAL, ATTR_OPERATION_METHOD, ATTR_OPERATION_REMOTE, + ATTR_OPERATION_TAG, DOMAIN, OPERATION_METHOD_AUTORELOCK, OPERATION_METHOD_KEYPAD, + OPERATION_METHOD_MANUAL, OPERATION_METHOD_MOBILE_DEVICE, OPERATION_METHOD_REMOTE, + OPERATION_METHOD_TAG, ) from .entity import AugustEntityMixin @@ -183,6 +187,8 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._device = device self._operated_remote = None self._operated_keypad = None + self._operated_manual = None + self._operated_tag = None self._operated_autorelock = None self._operated_time = None self._attr_unique_id = f"{self._device_id}_lock_operator" @@ -200,6 +206,8 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._attr_native_value = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad + self._operated_manual = lock_activity.operated_manual + self._operated_tag = lock_activity.operated_tag self._operated_autorelock = lock_activity.operated_autorelock self._attr_entity_picture = lock_activity.operator_thumbnail_url @@ -212,6 +220,10 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): attributes[ATTR_OPERATION_REMOTE] = self._operated_remote if self._operated_keypad is not None: attributes[ATTR_OPERATION_KEYPAD] = self._operated_keypad + if self._operated_manual is not None: + attributes[ATTR_OPERATION_MANUAL] = self._operated_manual + if self._operated_tag is not None: + attributes[ATTR_OPERATION_TAG] = self._operated_tag if self._operated_autorelock is not None: attributes[ATTR_OPERATION_AUTORELOCK] = self._operated_autorelock @@ -219,6 +231,10 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_REMOTE elif self._operated_keypad: attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_KEYPAD + elif self._operated_manual: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_MANUAL + elif self._operated_tag: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_TAG elif self._operated_autorelock: attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_AUTORELOCK else: @@ -241,6 +257,10 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._operated_remote = last_state.attributes[ATTR_OPERATION_REMOTE] if ATTR_OPERATION_KEYPAD in last_state.attributes: self._operated_keypad = last_state.attributes[ATTR_OPERATION_KEYPAD] + if ATTR_OPERATION_MANUAL in last_state.attributes: + self._operated_manual = last_state.attributes[ATTR_OPERATION_MANUAL] + if ATTR_OPERATION_TAG in last_state.attributes: + self._operated_tag = last_state.attributes[ATTR_OPERATION_TAG] if ATTR_OPERATION_AUTORELOCK in last_state.attributes: self._operated_autorelock = last_state.attributes[ATTR_OPERATION_AUTORELOCK] diff --git a/requirements_all.txt b/requirements_all.txt index aadc9698915..1b2bdde464c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2749,7 +2749,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.9.0 +yalexs==1.10.0 # homeassistant.components.yeelight yeelight==0.7.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0bc99890587..010bc77640d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2040,7 +2040,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.9.0 +yalexs==1.10.0 # homeassistant.components.yeelight yeelight==0.7.13 diff --git a/tests/components/august/fixtures/get_activity.lock_from_manual.json b/tests/components/august/fixtures/get_activity.lock_from_manual.json new file mode 100644 index 00000000000..e2fc195cfda --- /dev/null +++ b/tests/components/august/fixtures/get_activity.lock_from_manual.json @@ -0,0 +1,39 @@ +[ + { + "entities": { + "activity": "mockActivity2", + "house": "123", + "device": "online_with_doorsense", + "callingUser": "mockUserId2", + "otherUser": "deleted" + }, + "callingUser": { + "LastName": "elven princess", + "UserID": "mockUserId2", + "FirstName": "Your favorite" + }, + "otherUser": { + "LastName": "User", + "UserName": "deleteduser", + "FirstName": "Unknown", + "UserID": "deleted", + "PhoneNo": "deleted" + }, + "deviceType": "lock", + "deviceName": "MockHouseTDoor", + "action": "lock", + "dateTime": 1582007218000, + "info": { + "remote": false, + "keypad": false, + "manual": true, + "tag": false, + "DateLogActionID": "ABC+Time" + }, + "deviceID": "online_with_doorsense", + "house": { + "houseName": "MockHouse", + "houseID": "123" + } + } +] diff --git a/tests/components/august/fixtures/get_activity.unlock_from_manual.json b/tests/components/august/fixtures/get_activity.unlock_from_manual.json new file mode 100644 index 00000000000..e8bf95818ce --- /dev/null +++ b/tests/components/august/fixtures/get_activity.unlock_from_manual.json @@ -0,0 +1,39 @@ +[ + { + "entities": { + "activity": "mockActivity2", + "house": "123", + "device": "online_with_doorsense", + "callingUser": "mockUserId2", + "otherUser": "deleted" + }, + "callingUser": { + "LastName": "elven princess", + "UserID": "mockUserId2", + "FirstName": "Your favorite" + }, + "otherUser": { + "LastName": "User", + "UserName": "deleteduser", + "FirstName": "Unknown", + "UserID": "deleted", + "PhoneNo": "deleted" + }, + "deviceType": "lock", + "deviceName": "MockHouseTDoor", + "action": "unlock", + "dateTime": 1582007218000, + "info": { + "remote": false, + "keypad": false, + "manual": true, + "tag": false, + "DateLogActionID": "ABC+Time" + }, + "deviceID": "online_with_doorsense", + "house": { + "houseName": "MockHouse", + "houseID": "123" + } + } +] diff --git a/tests/components/august/fixtures/get_activity.unlock_from_tag.json b/tests/components/august/fixtures/get_activity.unlock_from_tag.json new file mode 100644 index 00000000000..57876428677 --- /dev/null +++ b/tests/components/august/fixtures/get_activity.unlock_from_tag.json @@ -0,0 +1,39 @@ +[ + { + "entities": { + "activity": "mockActivity2", + "house": "123", + "device": "online_with_doorsense", + "callingUser": "mockUserId2", + "otherUser": "deleted" + }, + "callingUser": { + "LastName": "elven princess", + "UserID": "mockUserId2", + "FirstName": "Your favorite" + }, + "otherUser": { + "LastName": "User", + "UserName": "deleteduser", + "FirstName": "Unknown", + "UserID": "deleted", + "PhoneNo": "deleted" + }, + "deviceType": "lock", + "deviceName": "MockHouseTDoor", + "action": "unlock", + "dateTime": 1582007218000, + "info": { + "remote": false, + "keypad": false, + "manual": false, + "tag": true, + "DateLogActionID": "ABC+Time" + }, + "deviceID": "online_with_doorsense", + "house": { + "houseName": "MockHouse", + "houseID": "123" + } + } +] diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index 7987ab88b1e..7877a9268a4 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -1,6 +1,14 @@ """The sensor tests for the august platform.""" -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from typing import Any + +from homeassistant import core as ha +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + STATE_UNKNOWN, +) +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import entity_registry as er from .mocks import ( @@ -11,6 +19,8 @@ from .mocks import ( _mock_lock_from_fixture, ) +from tests.common import mock_restore_cache_with_extra_data + async def test_create_doorbell(hass: HomeAssistant) -> None: """Test creation of a doorbell.""" @@ -255,6 +265,30 @@ async def test_lock_operator_remote(hass: HomeAssistant) -> None: ) +async def test_lock_operator_manual(hass: HomeAssistant) -> None: + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.lock_from_manual.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = er.async_get(hass) + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is True + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "manual" + + async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -297,3 +331,101 @@ async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: ] == "autorelock" ) + + +async def test_unlock_operator_manual(hass: HomeAssistant) -> None: + """Test operation of a lock manually.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.unlock_from_manual.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = er.async_get(hass) + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is True + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "manual" + + +async def test_unlock_operator_tag(hass: HomeAssistant) -> None: + """Test operation of a lock with a tag.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.unlock_from_tag.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = er.async_get(hass) + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is True + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "tag" + + +async def test_restored_state( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test restored state.""" + + entity_id = "sensor.online_with_doorsense_name_operator" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + fake_state = ha.State( + entity_id, + state="Tag Unlock", + attributes={ + "method": "tag", + "manual": False, + "remote": False, + "keypad": False, + "tag": True, + "autorelock": False, + ATTR_ENTITY_PICTURE: "image.png", + }, + ) + + # Home assistant is not running yet + hass.state = CoreState.not_running + last_reset = "2023-09-22T00:00:00.000000+00:00" + mock_restore_cache_with_extra_data( + hass, + [ + ( + fake_state, + { + "last_reset": last_reset, + }, + ) + ], + ) + + august_entry = await _create_august_with_devices(hass, [lock_one]) + august_entry.add_to_hass(hass) + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "Tag Unlock" + assert state.attributes["method"] == "tag" + assert state.attributes[ATTR_ENTITY_PICTURE] == "image.png" From 76cc04e52b88c052db8ddfa3f630390f4cc2680a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 22 Sep 2023 16:55:07 +0200 Subject: [PATCH 702/984] Remove obsolete methods in HVV departures (#100451) * Call async added to hass super in HVV departures * Remove obsolete methods --- .../components/hvv_departures/binary_sensor.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 2eeb6339214..0ec08e9c791 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -191,17 +191,3 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity): for k, v in self.coordinator.data[self.idx]["attributes"].items() if v is not None } - - # pylint: disable-next=hass-missing-super-call - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. - """ - await self.coordinator.async_request_refresh() From 964192d246dac91303079e7114e59cc8f7a1fc82 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 22 Sep 2023 21:21:44 +0200 Subject: [PATCH 703/984] Bump aiovodafone to 0.3.0 (#100729) --- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 68e7665b5ac..2d72e7d9482 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.2.0"] + "requirements": ["aiovodafone==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b2bdde464c..48359b77810 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -370,7 +370,7 @@ aiounifi==62 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.2.0 +aiovodafone==0.3.0 # homeassistant.components.waqi aiowaqi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 010bc77640d..ccaa6f62a77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -345,7 +345,7 @@ aiounifi==62 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.2.0 +aiovodafone==0.3.0 # homeassistant.components.waqi aiowaqi==0.2.1 From debee28856ced8c8e41898ba0a668ce414c7b719 Mon Sep 17 00:00:00 2001 From: Olen Date: Fri, 22 Sep 2023 21:31:17 +0200 Subject: [PATCH 704/984] Only get state once for all August sensor-tests (#100721) --- tests/components/august/test_sensor.py | 148 ++++++------------------- 1 file changed, 36 insertions(+), 112 deletions(-) diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index 7877a9268a4..d0da8ce6d53 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -149,34 +149,15 @@ async def test_lock_operator_bluetooth(hass: HomeAssistant) -> None: "sensor.online_with_doorsense_name_operator" ) assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == "Your favorite elven princess" - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "remote" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "keypad" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "autorelock" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "method" - ] - == "mobile" - ) + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "mobile" async def test_lock_operator_keypad(hass: HomeAssistant) -> None: @@ -193,34 +174,15 @@ async def test_lock_operator_keypad(hass: HomeAssistant) -> None: "sensor.online_with_doorsense_name_operator" ) assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == "Your favorite elven princess" - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "remote" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "keypad" - ] - is True - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "autorelock" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "method" - ] - == "keypad" - ) + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is True + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "keypad" async def test_lock_operator_remote(hass: HomeAssistant) -> None: @@ -235,34 +197,15 @@ async def test_lock_operator_remote(hass: HomeAssistant) -> None: "sensor.online_with_doorsense_name_operator" ) assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == "Your favorite elven princess" - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "remote" - ] - is True - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "keypad" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "autorelock" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "method" - ] - == "remote" - ) + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Your favorite elven princess" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is False + assert state.attributes["remote"] is True + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is False + assert state.attributes["method"] == "remote" async def test_lock_operator_manual(hass: HomeAssistant) -> None: @@ -303,34 +246,15 @@ async def test_lock_operator_autorelock(hass: HomeAssistant) -> None: "sensor.online_with_doorsense_name_operator" ) assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == "Auto Relock" - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "remote" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "keypad" - ] - is False - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "autorelock" - ] - is True - ) - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ - "method" - ] - == "autorelock" - ) + + state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert state.state == "Auto Relock" + assert state.attributes["manual"] is False + assert state.attributes["tag"] is False + assert state.attributes["remote"] is False + assert state.attributes["keypad"] is False + assert state.attributes["autorelock"] is True + assert state.attributes["method"] == "autorelock" async def test_unlock_operator_manual(hass: HomeAssistant) -> None: From 55c6d41d41b6e1478b4f0b93aade8d8e0c59ccc5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 22 Sep 2023 22:38:33 +0200 Subject: [PATCH 705/984] Bump aiocomelit to 0.0.8 (#100714) * Bump aiocomelit to 0.0.8 * fix import * fix tests --- homeassistant/components/comelit/config_flow.py | 4 ++-- homeassistant/components/comelit/coordinator.py | 4 ++-- homeassistant/components/comelit/manifest.json | 2 +- homeassistant/components/comelit/strings.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/comelit/test_config_flow.py | 14 +++++++------- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index dd6227a6583..b0c8e5aabe5 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aiocomelit import ComeliteSerialBridgeAPi, exceptions as aiocomelit_exceptions +from aiocomelit import ComeliteSerialBridgeApi, exceptions as aiocomelit_exceptions import voluptuous as vol from homeassistant import core, exceptions @@ -37,7 +37,7 @@ async def validate_input( ) -> dict[str, str]: """Validate the user input allows us to connect.""" - api = ComeliteSerialBridgeAPi(data[CONF_HOST], data[CONF_PIN]) + api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PIN]) try: await api.login() diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 1affd5046fe..1fcbd7c0d37 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta from typing import Any -from aiocomelit import ComeliteSerialBridgeAPi +from aiocomelit import ComeliteSerialBridgeApi import aiohttp from homeassistant.core import HomeAssistant @@ -22,7 +22,7 @@ class ComelitSerialBridge(DataUpdateCoordinator): self._host = host self._pin = pin - self.api = ComeliteSerialBridgeAPi(host, pin) + self.api = ComeliteSerialBridgeApi(host, pin) super().__init__( hass=hass, diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index fc7f2a3fc12..ee876434825 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.0.5"] + "requirements": ["aiocomelit==0.0.8"] } diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 6508f58412e..436fbfd5aec 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -3,7 +3,7 @@ "flow_title": "{host}", "step": { "reauth_confirm": { - "description": "Please enter the correct PIN for VEDO system: {host}", + "description": "Please enter the correct PIN for {host}", "data": { "pin": "[%key:common::config_flow::data::pin%]" } diff --git a/requirements_all.txt b/requirements_all.txt index 48359b77810..b67adc22e38 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -210,7 +210,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.5 +aiocomelit==0.0.8 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ccaa6f62a77..888d32af352 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.0.5 +aiocomelit==0.0.8 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index 2fb9e836efb..10f68f4d7c1 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -18,9 +18,9 @@ from tests.common import MockConfigEntry async def test_user(hass: HomeAssistant) -> None: """Test starting a flow by user.""" with patch( - "aiocomelit.api.ComeliteSerialBridgeAPi.login", + "aiocomelit.api.ComeliteSerialBridgeApi.login", ), patch( - "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + "aiocomelit.api.ComeliteSerialBridgeApi.logout", ), patch( "homeassistant.components.comelit.async_setup_entry" ) as mock_setup_entry, patch( @@ -64,7 +64,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result["step_id"] == "user" with patch( - "aiocomelit.api.ComeliteSerialBridgeAPi.login", + "aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect, ): result = await hass.config_entries.flow.async_configure( @@ -83,9 +83,9 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: mock_config.add_to_hass(hass) with patch( - "aiocomelit.api.ComeliteSerialBridgeAPi.login", + "aiocomelit.api.ComeliteSerialBridgeApi.login", ), patch( - "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + "aiocomelit.api.ComeliteSerialBridgeApi.logout", ), patch("homeassistant.components.comelit.async_setup_entry"), patch( "requests.get" ) as mock_request_get: @@ -127,9 +127,9 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> mock_config.add_to_hass(hass) with patch( - "aiocomelit.api.ComeliteSerialBridgeAPi.login", side_effect=side_effect + "aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect ), patch( - "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + "aiocomelit.api.ComeliteSerialBridgeApi.logout", ), patch( "homeassistant.components.comelit.async_setup_entry" ): From c6d62faff303f198da7a26ae49e276a9b23dc55b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Sep 2023 22:47:07 +0200 Subject: [PATCH 706/984] Avoid redundant calls to async_write_ha_state in mqtt cover (#100720) Avoid redundant calls to async_write_ha_state --- homeassistant/components/mqtt/cover.py | 28 ++++++++++++----- tests/components/mqtt/test_cover.py | 42 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 7423094d209..39c4090109c 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -45,9 +45,14 @@ from .const import ( DEFAULT_OPTIMISTIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -349,6 +354,7 @@ class MqttCover(MqttEntity, CoverEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_cover_tilt_position"}) def tilt_message_received(msg: ReceiveMessage) -> None: """Handle tilt updates.""" payload = self._tilt_status_template(msg.payload) @@ -361,6 +367,9 @@ class MqttCover(MqttEntity, CoverEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_is_closed", "_attr_is_closing", "_attr_is_opening"} + ) def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" payload = self._value_template(msg.payload) @@ -398,10 +407,18 @@ class MqttCover(MqttEntity, CoverEntity): return self._update_state(state) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_current_cover_position", + "_attr_current_cover_tilt_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, + ) def position_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT position messages.""" payload: ReceivePayloadType = self._get_position_template(msg.payload) @@ -444,8 +461,6 @@ class MqttCover(MqttEntity, CoverEntity): else STATE_OPEN ) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._config.get(CONF_GET_POSITION_TOPIC): topics["get_position_topic"] = { "topic": self._config.get(CONF_GET_POSITION_TOPIC), @@ -721,7 +736,6 @@ class MqttCover(MqttEntity, CoverEntity): ): level = self.find_percentage_in_range(payload) self._attr_current_cover_tilt_position = level - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) else: _LOGGER.warning( "Payload '%s' is out of range, must be between '%s' and '%s' inclusive", diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 2eec5f8374b..74dc48f4402 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -47,6 +47,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -69,6 +70,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -3666,3 +3668,43 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + cover.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + "position_topic": "position-topic", + "tilt_status_topic": "tilt-status-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "open", "closed"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("position-topic", "50", "100"), + ("tilt-status-topic", "50", "100"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From ad3cd723234385d598abeca82f5a69357681a05a Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 23 Sep 2023 00:42:32 +0200 Subject: [PATCH 707/984] Remove unneeded instance check (#100736) --- homeassistant/components/fibaro/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index ffa13749fa7..b4e5b47f297 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -514,7 +514,7 @@ class FibaroDevice(Entity): def update(self) -> None: """Update the available state of the entity.""" - if isinstance(self.fibaro_device, DeviceModel) and self.fibaro_device.has_dead: + if self.fibaro_device.has_dead: self._attr_available = not self.fibaro_device.dead From b0c9ff033ef1cabdef1eff3111dc91bdc8162a49 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 22 Sep 2023 19:29:00 -0500 Subject: [PATCH 708/984] Bump intents to 2023.9.22 (#100737) --- 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 9e0909b6dfc..2f733ead486 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.2.5", "home-assistant-intents==2023.8.2"] + "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.9.22"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6c65a08a97e..ff1c558a8d6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 home-assistant-frontend==20230911.0 -home-assistant-intents==2023.8.2 +home-assistant-intents==2023.9.22 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index b67adc22e38..41e1fa78614 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1001,7 +1001,7 @@ holidays==0.28 home-assistant-frontend==20230911.0 # homeassistant.components.conversation -home-assistant-intents==2023.8.2 +home-assistant-intents==2023.9.22 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 888d32af352..8adb66a1795 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -787,7 +787,7 @@ holidays==0.28 home-assistant-frontend==20230911.0 # homeassistant.components.conversation -home-assistant-intents==2023.8.2 +home-assistant-intents==2023.9.22 # homeassistant.components.home_connect homeconnect==0.7.2 From 2ef69d1504343e9af10940835dde86c1ccd342e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 23 Sep 2023 08:37:03 +0100 Subject: [PATCH 709/984] Improve Idasen Desk "no devices found" message (#100742) --- homeassistant/components/idasen_desk/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index e2be7e6deff..f7459906ac8 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -16,7 +16,7 @@ "not_supported": "Device not supported", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "No unconfigured devices found. Make sure that the desk is in Bluetooth pairing mode. Enter pairing mode by pressing the small button with the Bluetooth logo on the controller for about 3 seconds, until it starts blinking." } } } From a087ea8b3d4d28b332b2af131f658446f2c77481 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sat, 23 Sep 2023 00:40:07 -0700 Subject: [PATCH 710/984] Bump screenlogicpy to v0.9.1 (#100744) --- 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 9fc103dc8a8..4d9bbacf3a8 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.0"] + "requirements": ["screenlogicpy==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 41e1fa78614..d16032df8d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2367,7 +2367,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.0 +screenlogicpy==0.9.1 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8adb66a1795..4172f000564 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.0 +screenlogicpy==0.9.1 # homeassistant.components.backup securetar==2023.3.0 From 44fd60bd5334547d4719bbeaa3d71e411c6dc194 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sat, 23 Sep 2023 03:53:56 -0400 Subject: [PATCH 711/984] Bump ZHA dependencies (#100732) --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index c3fa6b1ff01..3610cd41425 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.3", + "bellows==0.36.4", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.103", @@ -30,7 +30,7 @@ "zigpy-xbee==0.18.2", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.4", - "universal-silabs-flasher==0.0.13" + "universal-silabs-flasher==0.0.14" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index d16032df8d5..f668cd135e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -513,7 +513,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.3 +bellows==0.36.4 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -2623,7 +2623,7 @@ unifi-discovery==1.1.7 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.13 +universal-silabs-flasher==0.0.14 # homeassistant.components.upb upb-lib==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4172f000564..a8ba9c09ded 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.3 +bellows==0.36.4 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -1932,7 +1932,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.7 # homeassistant.components.zha -universal-silabs-flasher==0.0.13 +universal-silabs-flasher==0.0.14 # homeassistant.components.upb upb-lib==0.5.4 From 439ca60cb6029329a71779ae107baf9f596690fc Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 23 Sep 2023 12:45:41 +0300 Subject: [PATCH 712/984] Fix Shelly Gen2 event get input name method (#100733) --- homeassistant/components/shelly/event.py | 4 ++-- homeassistant/components/shelly/utils.py | 12 ------------ tests/components/shelly/conftest.py | 2 +- tests/components/shelly/test_logbook.py | 2 +- tests/components/shelly/test_utils.py | 17 ++--------------- 5 files changed, 6 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 2abedf3cf9a..92624db3ce3 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -22,7 +22,7 @@ from .coordinator import ShellyRpcCoordinator, get_entry_data from .utils import ( async_remove_shelly_entity, get_device_entry_gen, - get_rpc_input_name, + get_rpc_entity_name, get_rpc_key_instances, is_rpc_momentary_input, ) @@ -90,7 +90,7 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) self._attr_unique_id = f"{coordinator.mac}-{key}" - self._attr_name = get_rpc_input_name(coordinator.device, key) + self._attr_name = get_rpc_entity_name(coordinator.device, key) self.entity_description = description async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 5633f674168..b64b76534be 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -285,22 +285,10 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["type"], info["type"])) -def get_rpc_input_name(device: RpcDevice, key: str) -> str: - """Get input name based from the device configuration.""" - input_config = device.config[key] - - if input_name := input_config.get("name"): - return f"{device.name} {input_name}" - - return f"{device.name} {key.replace(':', ' ').capitalize()}" - - def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") key = key.replace("em1data", "em1") - if device.config.get("switch:0"): - key = key.replace("input", "switch") device_name = device.name entity_name: str | None = None if key in device.config: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 00f88561880..438ca9b5ace 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -144,7 +144,7 @@ MOCK_BLOCKS = [ ] MOCK_CONFIG = { - "input:0": {"id": 0, "type": "button"}, + "input:0": {"id": 0, "name": "Test name input 0", "type": "button"}, "light:0": {"name": "test light_0"}, "switch:0": {"name": "test switch_0"}, "cover:0": {"name": "test cover_0"}, diff --git a/tests/components/shelly/test_logbook.py b/tests/components/shelly/test_logbook.py index 07db4776166..b73a5b552c5 100644 --- a/tests/components/shelly/test_logbook.py +++ b/tests/components/shelly/test_logbook.py @@ -108,7 +108,7 @@ async def test_humanify_shelly_click_event_rpc_device( assert event1["domain"] == DOMAIN assert ( event1["message"] - == "'single_push' click event for test switch_0 Input was fired" + == "'single_push' click event for Test name input 0 Input was fired" ) assert event2["name"] == "Shelly" diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index a163519c9d1..3d273ff3059 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -8,7 +8,6 @@ from homeassistant.components.shelly.utils import ( get_device_uptime, get_number_of_channels, get_rpc_channel_name, - get_rpc_input_name, get_rpc_input_triggers, is_block_momentary_input, ) @@ -207,20 +206,8 @@ async def test_get_block_input_triggers(mock_block_device, monkeypatch) -> None: async def test_get_rpc_channel_name(mock_rpc_device) -> None: """Test get RPC channel name.""" - assert get_rpc_channel_name(mock_rpc_device, "input:0") == "test switch_0" - assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name switch_3" - - -async def test_get_rpc_input_name(mock_rpc_device, monkeypatch) -> None: - """Test get RPC input name.""" - assert get_rpc_input_name(mock_rpc_device, "input:0") == "Test name Input 0" - - monkeypatch.setitem( - mock_rpc_device.config, - "input:0", - {"id": 0, "type": "button", "name": "Input name"}, - ) - assert get_rpc_input_name(mock_rpc_device, "input:0") == "Test name Input name" + assert get_rpc_channel_name(mock_rpc_device, "input:0") == "Test name input 0" + assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name input_3" async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: From 7a1ee98bb6e0170bc041f7a94cf610190ef29698 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 23 Sep 2023 13:28:14 +0200 Subject: [PATCH 713/984] Fix handling of unit system change in sensor (#100715) --- homeassistant/components/sensor/__init__.py | 4 +- tests/components/sensor/test_init.py | 60 +++++++++++++++++---- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 4faeca33df5..2cab631d1f0 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -347,7 +347,7 @@ class SensorEntity(Entity): """Return initial entity options. These will be stored in the entity registry the first time the entity is seen, - and then never updated. + and then only updated if the unit system is changed. """ suggested_unit_of_measurement = self._get_initial_suggested_unit() @@ -785,7 +785,7 @@ class SensorEntity(Entity): registry = er.async_get(self.hass) initial_options = self.get_initial_entity_options() or {} registry.async_update_entity_options( - self.entity_id, + self.registry_entry.entity_id, f"{DOMAIN}.private", initial_options.get(f"{DOMAIN}.private"), ) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index b7682eb2ec2..07d44207c68 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -44,6 +44,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import ( MockConfigEntry, + MockEntityPlatform, MockModule, MockPlatform, async_mock_restore_state_shutdown_restart, @@ -2177,27 +2178,24 @@ async def test_unit_conversion_update( entity_registry = er.async_get(hass) platform = getattr(hass.components, "test.sensor") - platform.init(empty=True) - platform.ENTITIES["0"] = platform.MockSensor( + entity0 = platform.MockSensor( name="Test 0", device_class=device_class, native_unit_of_measurement=native_unit, native_value=str(native_value), unique_id="very_unique", ) - entity0 = platform.ENTITIES["0"] - platform.ENTITIES["1"] = platform.MockSensor( + entity1 = platform.MockSensor( name="Test 1", device_class=device_class, native_unit_of_measurement=native_unit, native_value=str(native_value), unique_id="very_unique_1", ) - entity1 = platform.ENTITIES["1"] - platform.ENTITIES["2"] = platform.MockSensor( + entity2 = platform.MockSensor( name="Test 2", device_class=device_class, native_unit_of_measurement=native_unit, @@ -2205,9 +2203,8 @@ async def test_unit_conversion_update( suggested_unit_of_measurement=suggested_unit, unique_id="very_unique_2", ) - entity2 = platform.ENTITIES["2"] - platform.ENTITIES["3"] = platform.MockSensor( + entity3 = platform.MockSensor( name="Test 3", device_class=device_class, native_unit_of_measurement=native_unit, @@ -2215,9 +2212,33 @@ async def test_unit_conversion_update( suggested_unit_of_measurement=suggested_unit, unique_id="very_unique_3", ) - entity3 = platform.ENTITIES["3"] - assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + entity4 = platform.MockSensor( + name="Test 4", + device_class=device_class, + native_unit_of_measurement=native_unit, + native_value=str(native_value), + unique_id="very_unique_4", + ) + + entity_platform = MockEntityPlatform( + hass, domain="sensor", platform_name="test", platform=None + ) + await entity_platform.async_add_entities((entity0, entity1, entity2, entity3)) + + # Pre-register entity4 + entry = entity_registry.async_get_or_create( + "sensor", "test", entity4.unique_id, unit_of_measurement=automatic_unit_1 + ) + entity4_entity_id = entry.entity_id + entity_registry.async_update_entity_options( + entity4_entity_id, + "sensor.private", + { + "suggested_unit_of_measurement": automatic_unit_1, + }, + ) + await hass.async_block_till_done() # Registered entity -> Follow automatic unit conversion @@ -2320,6 +2341,25 @@ async def test_unit_conversion_update( assert state.state == suggested_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit + # Entity 4 still has a pending request to refresh entity options + entry = entity_registry.async_get(entity4_entity_id) + assert entry.options == { + "sensor.private": { + "refresh_initial_entity_options": True, + "suggested_unit_of_measurement": automatic_unit_1, + } + } + + # Add entity 4, the pending request to refresh entity options should be handled + await entity_platform.async_add_entities((entity4,)) + + state = hass.states.get(entity4_entity_id) + assert state.state == automatic_state_2 + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit_2 + + entry = entity_registry.async_get(entity4_entity_id) + assert entry.options == {} + class MockFlow(ConfigFlow): """Test flow.""" From 173b70c850cbe5915f2d8697af54bf375a87ce3a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 23 Sep 2023 13:30:11 +0200 Subject: [PATCH 714/984] Fix weather template forecast attributes (#100748) --- homeassistant/components/template/weather.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index a04fc7a641d..d815655d775 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -44,7 +44,12 @@ from homeassistant.util.unit_conversion import ( from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf -CHECK_FORECAST_KEYS = set().union(Forecast.__annotations__.keys()) +CHECK_FORECAST_KEYS = ( + set().union(Forecast.__annotations__.keys()) + # Manually add the forecast resulting attributes that only exists + # as native_* in the Forecast definition + .union(("apparent_temperature", "wind_gust_speed", "dew_point")) +) CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT, @@ -434,7 +439,8 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): diff_result = set().union(forecast.keys()).difference(CHECK_FORECAST_KEYS) if diff_result: raise vol.Invalid( - "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" + f"Only valid keys in Forecast are allowed, unallowed keys: ({diff_result}), " + "see Weather documentation https://www.home-assistant.io/integrations/weather/" ) if forecast_type == "twice_daily" and "is_daytime" not in forecast: raise vol.Invalid( From 5c5dff034c166e86a52468399833114d362d6d18 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 23 Sep 2023 14:03:57 +0000 Subject: [PATCH 715/984] Add `event` platform for Shelly gen1 devices (#100655) * Initial commit * Use description.key * Add translations * Check event_types * Rename input_id to channel * Fix removeal confition * Add tests * Sort classes and consts * Use ShellyBlockEntity class * Update tests * Update homeassistant/components/shelly/event.py Co-authored-by: Shay Levy --------- Co-authored-by: Shay Levy --- homeassistant/components/shelly/__init__.py | 1 + .../components/shelly/coordinator.py | 18 +++ homeassistant/components/shelly/event.py | 106 ++++++++++++++++-- homeassistant/components/shelly/strings.json | 8 +- tests/components/shelly/test_event.py | 44 ++++++++ 5 files changed, 166 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 29a0506fcc0..5efc5c849d7 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -58,6 +58,7 @@ BLOCK_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index c19aac93dab..1a8081b2053 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -170,6 +170,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): self._last_input_events_count: dict = {} self._last_target_temp: float | None = None self._push_update_failures: int = 0 + self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] entry.async_on_unload( self.async_add_listener(self._async_device_updates_handler) @@ -178,6 +179,19 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) ) + @callback + def async_subscribe_input_events( + self, input_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to input events.""" + + def _unsubscribe() -> None: + self._input_event_listeners.remove(input_event_callback) + + self._input_event_listeners.append(input_event_callback) + + return _unsubscribe + @callback def _async_device_updates_handler(self) -> None: """Handle device updates.""" @@ -242,6 +256,10 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): continue if event_type in INPUTS_EVENTS_DICT: + for event_callback in self._input_event_listeners: + event_callback( + {"channel": channel, "event": INPUTS_EVENTS_DICT[event_type]} + ) self.hass.bus.async_fire( EVENT_SHELLY_CLICK, { diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 92624db3ce3..1b0fedd5cda 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -3,7 +3,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final + +from aioshelly.block_device import Block from homeassistant.components.event import ( DOMAIN as EVENT_DOMAIN, @@ -17,25 +19,46 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import RPC_INPUTS_EVENTS_TYPES -from .coordinator import ShellyRpcCoordinator, get_entry_data +from .const import ( + BASIC_INPUTS_EVENTS_TYPES, + RPC_INPUTS_EVENTS_TYPES, + SHIX3_1_INPUTS_EVENTS_TYPES, +) +from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data +from .entity import ShellyBlockEntity from .utils import ( async_remove_shelly_entity, get_device_entry_gen, get_rpc_entity_name, get_rpc_key_instances, + is_block_momentary_input, is_rpc_momentary_input, ) @dataclass -class ShellyEventDescription(EventEntityDescription): +class ShellyBlockEventDescription(EventEntityDescription): + """Class to describe Shelly event.""" + + removal_condition: Callable[[dict, Block], bool] | None = None + + +@dataclass +class ShellyRpcEventDescription(EventEntityDescription): """Class to describe Shelly event.""" removal_condition: Callable[[dict, dict, str], bool] | None = None -RPC_EVENT: Final = ShellyEventDescription( +BLOCK_EVENT: Final = ShellyBlockEventDescription( + key="input", + translation_key="input", + device_class=EventDeviceClass.BUTTON, + removal_condition=lambda settings, block: not is_block_momentary_input( + settings, block, True + ), +) +RPC_EVENT: Final = ShellyRpcEventDescription( key="input", translation_key="input", device_class=EventDeviceClass.BUTTON, @@ -52,11 +75,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" + entities: list[ShellyBlockEvent | ShellyRpcEvent] = [] + + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None + if get_device_entry_gen(config_entry) == 2: coordinator = get_entry_data(hass)[config_entry.entry_id].rpc - assert coordinator + if TYPE_CHECKING: + assert coordinator - entities = [] key_instances = get_rpc_key_instances(coordinator.device.status, RPC_EVENT.key) for key in key_instances: @@ -67,21 +94,80 @@ async def async_setup_entry( async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) else: entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT)) + else: + coordinator = get_entry_data(hass)[config_entry.entry_id].block + if TYPE_CHECKING: + assert coordinator + assert coordinator.device.blocks - async_add_entities(entities) + for block in coordinator.device.blocks: + if ( + "inputEvent" not in block.sensor_ids + or "inputEventCnt" not in block.sensor_ids + ): + continue + + if BLOCK_EVENT.removal_condition and BLOCK_EVENT.removal_condition( + coordinator.device.settings, block + ): + channel = int(block.channel or 0) + 1 + unique_id = f"{coordinator.mac}-{block.description}-{channel}" + async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) + else: + entities.append(ShellyBlockEvent(coordinator, block, BLOCK_EVENT)) + + async_add_entities(entities) + + +class ShellyBlockEvent(ShellyBlockEntity, EventEntity): + """Represent Block event entity.""" + + _attr_should_poll = False + entity_description: ShellyBlockEventDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + description: ShellyBlockEventDescription, + ) -> None: + """Initialize Shelly entity.""" + super().__init__(coordinator, block) + self.channel = channel = int(block.channel or 0) + 1 + self._attr_unique_id = f"{super().unique_id}-{channel}" + + if coordinator.model == "SHIX3-1": + self._attr_event_types = list(SHIX3_1_INPUTS_EVENTS_TYPES) + else: + self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES) + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_input_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: dict[str, Any]) -> None: + """Handle the demo button event.""" + if event["channel"] == self.channel: + self._trigger_event(event["event"]) + self.async_write_ha_state() class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Represent RPC event entity.""" _attr_should_poll = False - entity_description: ShellyEventDescription + entity_description: ShellyRpcEventDescription def __init__( self, coordinator: ShellyRpcCoordinator, key: str, - description: ShellyEventDescription, + description: ShellyRpcEventDescription, ) -> None: """Initialize Shelly entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index d2e72ee81da..b12ad3e4823 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -106,9 +106,15 @@ "btn_down": "Button down", "btn_up": "Button up", "double_push": "Double push", + "double": "Double push", "long_push": "Long push", + "long_single": "Long push and then short push", + "long": "Long push", + "single_long": "Short push and then long push", "single_push": "Single push", - "triple_push": "Triple push" + "single": "Single push", + "triple_push": "Triple push", + "triple": "Triple push" } } } diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index 8222e42408b..b7824d8d7ac 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_registry import async_get from . import init_integration, inject_rpc_device_event, register_entity +DEVICE_BLOCK_ID = 4 + async def test_rpc_button(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: """Test RPC device event.""" @@ -68,3 +70,45 @@ async def test_rpc_event_removal( await init_integration(hass, 2) assert registry.async_get(entity_id) is None + + +async def test_block_event(hass: HomeAssistant, monkeypatch, mock_block_device) -> None: + """Test block device event.""" + await init_integration(hass, 1) + entity_id = "event.test_name_channel_1" + registry = async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered(["single", "long"]) + assert state.attributes.get(ATTR_EVENT_TYPE) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-relay_0-1" + + monkeypatch.setattr( + mock_block_device.blocks[DEVICE_BLOCK_ID], + "sensor_ids", + {"inputEvent": "L", "inputEventCnt": 0}, + ) + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "inputEvent", "L") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) == "long" + + +async def test_block_event_shix3_1(hass: HomeAssistant, mock_block_device) -> None: + """Test block device event for SHIX3-1.""" + await init_integration(hass, 1, model="SHIX3-1") + entity_id = "event.test_name_channel_1" + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["double", "long", "long_single", "single", "single_long", "triple"] + ) From 6383cafeb9643266d7a7f6dc1ec4519b3775b58b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 23 Sep 2023 17:38:21 +0200 Subject: [PATCH 716/984] Update home-assistant/wheels to 2023.09.1 (#100758) * Update home-assistant/wheels to 2023.09.0 * Update home-assistant/wheels to 2023.09.1 --- .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 7636d628e41..0bf89a8e050 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -97,7 +97,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.04.0 + uses: home-assistant/wheels@2023.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -178,7 +178,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.04.0 + uses: home-assistant/wheels@2023.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -192,7 +192,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.04.0 + uses: home-assistant/wheels@2023.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -206,7 +206,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.04.0 + uses: home-assistant/wheels@2023.09.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 14b39c3bcf56986b86b73690c981a3541f58a18b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 23 Sep 2023 19:05:52 +0200 Subject: [PATCH 717/984] Correct some typo's in MQTT issue string (#100759) --- 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 d1b63b331ed..b28f16cb404 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -18,7 +18,7 @@ }, "deprecated_climate_aux_property": { "title": "MQTT entities with auxiliary heat support found", - "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deperated config options from your configration and restart HA to fix this issue." + "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." } }, "config": { From a2730fb29d430a22d3059bf68a6bc13fdb2ccf8b Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 23 Sep 2023 19:13:03 +0200 Subject: [PATCH 718/984] Fibaro finish separation of scenes (#100734) --- homeassistant/components/fibaro/__init__.py | 16 ++++++++-------- homeassistant/components/fibaro/scene.py | 6 ++---- tests/components/fibaro/conftest.py | 5 +---- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index b4e5b47f297..0af6cf02586 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -8,6 +8,7 @@ from typing import Any from pyfibaro.fibaro_client import FibaroClient from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_scene import SceneModel from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry @@ -87,9 +88,11 @@ class FibaroController: self._import_plugins = config[CONF_IMPORT_PLUGINS] self._room_map = None # Mapping roomId to room object self._device_map = None # Mapping deviceId to device object - self.fibaro_devices: dict[Platform, list] = defaultdict( + self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list ) # List of devices by entity platform + # All scenes + self._scenes: list[SceneModel] = [] self._callbacks: dict[Any, Any] = {} # Update value callbacks by deviceId self.hub_serial: str # Unique serial number of the hub self.hub_name: str # The friendly name of the hub @@ -115,7 +118,7 @@ class FibaroController: self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._read_devices() - self._read_scenes() + self._scenes = self._client.read_scenes() return True def connect_with_error_handling(self) -> None: @@ -282,12 +285,9 @@ class FibaroController: room = self._room_map.get(room_id) return room.name if room else None - def _read_scenes(self): - scenes = self._client.read_scenes() - for device in scenes: - device.fibaro_controller = self - self.fibaro_devices[Platform.SCENE].append(device) - _LOGGER.debug("Scene -> %s", device) + def read_scenes(self) -> list[SceneModel]: + """Return list of scenes.""" + return self._scenes def _read_devices(self): """Read and process the device list.""" diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 36d2666f97d..7ae8bff151f 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -7,7 +7,6 @@ from pyfibaro.fibaro_scene import SceneModel from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,7 +24,7 @@ async def async_setup_entry( """Perform the setup for Fibaro scenes.""" controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [FibaroScene(scene) for scene in controller.fibaro_devices[Platform.SCENE]], + [FibaroScene(scene, controller) for scene in controller.read_scenes()], True, ) @@ -33,11 +32,10 @@ async def async_setup_entry( class FibaroScene(Scene): """Representation of a Fibaro scene entity.""" - def __init__(self, fibaro_scene: SceneModel) -> None: + def __init__(self, fibaro_scene: SceneModel, controller: FibaroController) -> None: """Initialize the Fibaro scene.""" self._fibaro_scene = fibaro_scene - controller: FibaroController = fibaro_scene.fibaro_controller room_name = controller.get_room_name(fibaro_scene.room_id) if not room_name: room_name = "Unknown" diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 1a3f9b083b8..2b6580c3191 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -47,10 +47,7 @@ async def setup_platform( controller_mock = Mock() controller_mock.hub_serial = "HC2-111111" controller_mock.get_room_name.return_value = room_name - controller_mock.fibaro_devices = {Platform.SCENE: scenes} - - for scene in scenes: - scene.fibaro_controller = controller_mock + controller_mock.read_scenes.return_value = scenes hass.data[DOMAIN] = {config_entry.entry_id: controller_mock} await hass.config_entries.async_forward_entry_setup(config_entry, platform) From d833c1a598d2e1b096331573b69687cd16919adc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 23 Sep 2023 11:32:52 -0700 Subject: [PATCH 719/984] Add myself as a fitbit codeowner (#100766) --- CODEOWNERS | 1 + homeassistant/components/fitbit/manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4fdf8845fe9..9b54d6fa4f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -390,6 +390,7 @@ build.json @home-assistant/supervisor /tests/components/fireservicerota/ @cyberjunky /homeassistant/components/firmata/ @DaAwesomeP /tests/components/firmata/ @DaAwesomeP +/homeassistant/components/fitbit/ @allenporter /homeassistant/components/fivem/ @Sander0542 /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index 510489a197b..510fe8da900 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -1,7 +1,7 @@ { "domain": "fitbit", "name": "Fitbit", - "codeowners": [], + "codeowners": ["@allenporter"], "dependencies": ["configurator", "http"], "documentation": "https://www.home-assistant.io/integrations/fitbit", "iot_class": "cloud_polling", From a826f266429d73c8df4eb807aff76542405c5490 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 23 Sep 2023 14:37:57 -0400 Subject: [PATCH 720/984] Bump pyschlage to 2023.9.1 (#100760) --- 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 fb4ccc81dee..3568692c6ca 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.0"] + "requirements": ["pyschlage==2023.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f668cd135e7..21ed0fb403d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1998,7 +1998,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.9.0 +pyschlage==2023.9.1 # homeassistant.components.sensibo pysensibo==1.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a8ba9c09ded..92bc2bd800f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1496,7 +1496,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.9.0 +pyschlage==2023.9.1 # homeassistant.components.sensibo pysensibo==1.0.33 From 71aef4e95a316e77708301c8bec39dad382a6d85 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 23 Sep 2023 21:04:29 +0200 Subject: [PATCH 721/984] Add media extractor tests (#100462) * Add tests for media extractor * Complete test coverage * Fix test dep --- .coveragerc | 1 - CODEOWNERS | 1 + requirements_test_all.txt | 3 + tests/components/media_extractor/__init__.py | 50 + tests/components/media_extractor/conftest.py | 54 + tests/components/media_extractor/const.py | 17 + .../fixtures/no_formats_info.json | 85 + .../fixtures/no_formats_result.json | 124 + .../fixtures/no_formats_result_bestaudio.json | 124 + .../fixtures/soundcloud_info.json | 114 + .../fixtures/soundcloud_result.json | 192 ++ .../fixtures/soundcloud_result_bestaudio.json | 192 ++ .../fixtures/youtube_1_info.json | 1430 +++++++++++ .../fixtures/youtube_1_result.json | 2264 +++++++++++++++++ .../fixtures/youtube_1_result_bestaudio.json | 2264 +++++++++++++++++ .../fixtures/youtube_empty_playlist_info.json | 49 + .../fixtures/youtube_playlist_info.json | 265 ++ .../fixtures/youtube_playlist_result.json | 1351 ++++++++++ .../media_extractor/snapshots/test_init.ambr | 120 + tests/components/media_extractor/test_init.py | 211 ++ 20 files changed, 8910 insertions(+), 1 deletion(-) create mode 100644 tests/components/media_extractor/__init__.py create mode 100644 tests/components/media_extractor/conftest.py create mode 100644 tests/components/media_extractor/const.py create mode 100644 tests/components/media_extractor/fixtures/no_formats_info.json create mode 100644 tests/components/media_extractor/fixtures/no_formats_result.json create mode 100644 tests/components/media_extractor/fixtures/no_formats_result_bestaudio.json create mode 100644 tests/components/media_extractor/fixtures/soundcloud_info.json create mode 100644 tests/components/media_extractor/fixtures/soundcloud_result.json create mode 100644 tests/components/media_extractor/fixtures/soundcloud_result_bestaudio.json create mode 100644 tests/components/media_extractor/fixtures/youtube_1_info.json create mode 100644 tests/components/media_extractor/fixtures/youtube_1_result.json create mode 100644 tests/components/media_extractor/fixtures/youtube_1_result_bestaudio.json create mode 100644 tests/components/media_extractor/fixtures/youtube_empty_playlist_info.json create mode 100644 tests/components/media_extractor/fixtures/youtube_playlist_info.json create mode 100644 tests/components/media_extractor/fixtures/youtube_playlist_result.json create mode 100644 tests/components/media_extractor/snapshots/test_init.ambr create mode 100644 tests/components/media_extractor/test_init.py diff --git a/.coveragerc b/.coveragerc index da0b312ac83..d9182594356 100644 --- a/.coveragerc +++ b/.coveragerc @@ -719,7 +719,6 @@ omit = homeassistant/components/matter/__init__.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py - homeassistant/components/media_extractor/* homeassistant/components/mediaroom/media_player.py homeassistant/components/melcloud/__init__.py homeassistant/components/melcloud/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 9b54d6fa4f0..e1afc58ae98 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -741,6 +741,7 @@ build.json @home-assistant/supervisor /homeassistant/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery /homeassistant/components/media_extractor/ @joostlek +/tests/components/media_extractor/ @joostlek /homeassistant/components/media_player/ @home-assistant/core /tests/components/media_player/ @home-assistant/core /homeassistant/components/media_source/ @hunterjm diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 92bc2bd800f..3c469e25e54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2054,6 +2054,9 @@ youless-api==1.0.1 # homeassistant.components.youtube youtubeaio==1.1.5 +# homeassistant.components.media_extractor +yt-dlp==2023.7.6 + # homeassistant.components.zamg zamg==0.3.0 diff --git a/tests/components/media_extractor/__init__.py b/tests/components/media_extractor/__init__.py new file mode 100644 index 00000000000..d6faa60d3b4 --- /dev/null +++ b/tests/components/media_extractor/__init__.py @@ -0,0 +1,50 @@ +"""The tests for Media Extractor integration.""" +from typing import Any + +from tests.common import load_json_object_fixture +from tests.components.media_extractor.const import ( + AUDIO_QUERY, + NO_FORMATS_RESPONSE, + SOUNDCLOUD_TRACK, + YOUTUBE_EMPTY_PLAYLIST, + YOUTUBE_PLAYLIST, + YOUTUBE_VIDEO, +) + + +def _get_base_fixture(url: str) -> str: + return { + YOUTUBE_VIDEO: "youtube_1", + YOUTUBE_PLAYLIST: "youtube_playlist", + YOUTUBE_EMPTY_PLAYLIST: "youtube_empty_playlist", + SOUNDCLOUD_TRACK: "soundcloud", + NO_FORMATS_RESPONSE: "no_formats", + }[url] + + +def _get_query_fixture(query: str | None) -> str: + return {AUDIO_QUERY: "_bestaudio", "best": ""}.get(query, "") + + +class MockYoutubeDL: + """Mock object for YoutubeDL.""" + + _fixture = None + + def __init__(self, params: dict[str, Any]) -> None: + """Initialize mock object for YoutubeDL.""" + self.params = params + + def extract_info(self, url: str, *, process: bool = False) -> dict[str, Any]: + """Return info.""" + self._fixture = _get_base_fixture(url) + return load_json_object_fixture(f"media_extractor/{self._fixture}_info.json") + + def process_ie_result( + self, selected_media: dict[str, Any], *, download: bool = False + ) -> dict[str, Any]: + """Return result.""" + query_fixture = _get_query_fixture(self.params["format"]) + return load_json_object_fixture( + f"media_extractor/{self._fixture}_result{query_fixture}.json" + ) diff --git a/tests/components/media_extractor/conftest.py b/tests/components/media_extractor/conftest.py new file mode 100644 index 00000000000..8c8a6d6fb8d --- /dev/null +++ b/tests/components/media_extractor/conftest.py @@ -0,0 +1,54 @@ +"""The tests for Media Extractor integration.""" +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.media_extractor import DOMAIN +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service +from tests.components.media_extractor import MockYoutubeDL +from tests.components.media_extractor.const import AUDIO_QUERY + + +@pytest.fixture(autouse=True) +async def setup_homeassistant(hass: HomeAssistant): + """Set up the homeassistant integration.""" + await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture(autouse=True) +async def setup_media_player(hass: HomeAssistant) -> None: + """Set up the demo media player.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "media_player", "play_media") + + +@pytest.fixture(name="mock_youtube_dl") +async def setup_mock_yt_dlp(hass: HomeAssistant) -> MockYoutubeDL: + """Mock YoutubeDL.""" + mock = MockYoutubeDL({}) + with patch("homeassistant.components.media_extractor.YoutubeDL", return_value=mock): + yield mock + + +@pytest.fixture(name="empty_media_extractor_config") +def empty_media_extractor_config() -> dict[str, Any]: + """Return base media extractor config.""" + return {DOMAIN: {}} + + +@pytest.fixture(name="audio_media_extractor_config") +def audio_media_extractor_config() -> dict[str, Any]: + """Media extractor config for audio.""" + return {DOMAIN: {"default_query": AUDIO_QUERY}} diff --git a/tests/components/media_extractor/const.py b/tests/components/media_extractor/const.py new file mode 100644 index 00000000000..ce309708823 --- /dev/null +++ b/tests/components/media_extractor/const.py @@ -0,0 +1,17 @@ +"""The tests for Media Extractor integration.""" + +AUDIO_QUERY = "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio" + +YOUTUBE_VIDEO = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +YOUTUBE_PLAYLIST = ( + "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP" +) +YOUTUBE_EMPTY_PLAYLIST = ( + "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO" +) + +SOUNDCLOUD_TRACK = "https://soundcloud.com/bruttoband/brutto-11" + +# The ytdlp code indicates formats can be none. +# This acts as temporary fixtures until a real situation is found. +NO_FORMATS_RESPONSE = "https://test.com/abc" diff --git a/tests/components/media_extractor/fixtures/no_formats_info.json b/tests/components/media_extractor/fixtures/no_formats_info.json new file mode 100644 index 00000000000..bc475d16102 --- /dev/null +++ b/tests/components/media_extractor/fixtures/no_formats_info.json @@ -0,0 +1,85 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16 + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20 + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32 + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47 + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67 + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100 + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300 + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400 + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500 + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290864, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/no_formats_result.json b/tests/components/media_extractor/fixtures/no_formats_result.json new file mode 100644 index 00000000000..744bf76f93c --- /dev/null +++ b/tests/components/media_extractor/fixtures/no_formats_result.json @@ -0,0 +1,124 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16, + "resolution": "16x16" + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20, + "resolution": "20x20" + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32, + "resolution": "32x32" + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47, + "resolution": "47x47" + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67, + "resolution": "67x67" + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100, + "resolution": "100x100" + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300, + "resolution": "300x300" + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400, + "resolution": "400x400" + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500, + "resolution": "500x500" + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290864, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {}, + "playlist": null, + "playlist_index": null, + "thumbnail": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "display_id": "223644255", + "fulltitle": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "duration_string": "3:49", + "upload_date": "20150913", + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694798244, + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" +} diff --git a/tests/components/media_extractor/fixtures/no_formats_result_bestaudio.json b/tests/components/media_extractor/fixtures/no_formats_result_bestaudio.json new file mode 100644 index 00000000000..664c61e96ae --- /dev/null +++ b/tests/components/media_extractor/fixtures/no_formats_result_bestaudio.json @@ -0,0 +1,124 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16, + "resolution": "16x16" + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20, + "resolution": "20x20" + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32, + "resolution": "32x32" + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47, + "resolution": "47x47" + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67, + "resolution": "67x67" + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100, + "resolution": "100x100" + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300, + "resolution": "300x300" + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400, + "resolution": "400x400" + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500, + "resolution": "500x500" + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290870, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {}, + "playlist": null, + "playlist_index": null, + "thumbnail": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "display_id": "223644255", + "fulltitle": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "duration_string": "3:49", + "upload_date": "20150913", + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694798829, + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" +} diff --git a/tests/components/media_extractor/fixtures/soundcloud_info.json b/tests/components/media_extractor/fixtures/soundcloud_info.json new file mode 100644 index 00000000000..676ef8edcf2 --- /dev/null +++ b/tests/components/media_extractor/fixtures/soundcloud_info.json @@ -0,0 +1,114 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16 + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20 + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32 + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47 + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67 + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100 + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300 + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400 + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500 + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290864, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "formats": [ + { + "url": "https://cf-hls-media.sndcdn.com/playlist/50remGX1OqRY.128.mp3/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1tZWRpYS5zbmRjZG4uY29tL3BsYXlsaXN0LzUwcmVtR1gxT3FSWS4xMjgubXAzL3BsYXlsaXN0Lm0zdTgqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=Nz4jXIokS4VBJ3AB~qzud7B2lEiGOZsu~k3BAOw4MdaT3Vqpq2wFoN9Nj5adjhPziclvTCitiro7oAYgHx-T6sKoUkgXXaanrhpUnmtnSWKSGHMIcGRjZD5~WnN9jc3VXt7kC1-1UMR3eiCgsNs~~iZSdr0EOk-W6IIJZ-XdIHJFekpcf3tt56uyoyicFfgRndjfbB9qijp3w1JVbNrAWL0oOHjk-76zspjytDQkunxtcT1cVd5VC1FiLd1azwX9bWkCHsb4Kk2sE0RRhycN7FePoG1FQysuN8deZ17NYD0CVi6QaHYzoQKrARODt1J-o0xAZWTbiwSobWcyZVc2ug__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "hls_mp3_128", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none" + }, + { + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none" + }, + { + "url": "https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTQ3OTg1OTN9fX1dfQ__&Signature=R2kxbkBwFOP8olMikw3IMYlC5gqMY173VH07Rq9Aq1vDkGbQwZzMd2OocIFQlsIhHDacH7WKPWdAqMFzuSb4KpHo6hi7KouM3dxXY5QgzQPRtfACyIbR3Kka7DGSVScJaCejp1xy5YoqEIhr8N36iogBPELiZs1jDAHf99cJnMHFN8SCMrej2BSNMSbCAaUXN2TlyViMR3yiG-kGY-RIs8pHDg0QE-M1tPAAAc94GynFDhbexHqFl-QIFQ4RxG9Pu7ooXqEG~xV848fgOUPUYC3yjCDZ7KKkW5BexSPD-ebavodz6kNU62GdIeuNzY3g-wftNLSQgwaMkg3aWj3VMA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "opus", + "abr": 64, + "format_id": "hls_opus_64", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none" + } + ], + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/soundcloud_result.json b/tests/components/media_extractor/fixtures/soundcloud_result.json new file mode 100644 index 00000000000..733d5650736 --- /dev/null +++ b/tests/components/media_extractor/fixtures/soundcloud_result.json @@ -0,0 +1,192 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16, + "resolution": "16x16" + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20, + "resolution": "20x20" + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32, + "resolution": "32x32" + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47, + "resolution": "47x47" + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67, + "resolution": "67x67" + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100, + "resolution": "100x100" + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300, + "resolution": "300x300" + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400, + "resolution": "400x400" + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500, + "resolution": "500x500" + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290864, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "formats": [ + { + "url": "https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTQ3OTg1OTN9fX1dfQ__&Signature=R2kxbkBwFOP8olMikw3IMYlC5gqMY173VH07Rq9Aq1vDkGbQwZzMd2OocIFQlsIhHDacH7WKPWdAqMFzuSb4KpHo6hi7KouM3dxXY5QgzQPRtfACyIbR3Kka7DGSVScJaCejp1xy5YoqEIhr8N36iogBPELiZs1jDAHf99cJnMHFN8SCMrej2BSNMSbCAaUXN2TlyViMR3yiG-kGY-RIs8pHDg0QE-M1tPAAAc94GynFDhbexHqFl-QIFQ4RxG9Pu7ooXqEG~xV848fgOUPUYC3yjCDZ7KKkW5BexSPD-ebavodz6kNU62GdIeuNzY3g-wftNLSQgwaMkg3aWj3VMA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "opus", + "abr": 64, + "format_id": "hls_opus_64", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "opus", + "video_ext": "none", + "vbr": 0, + "tbr": 64, + "format": "hls_opus_64 - audio only" + }, + { + "url": "https://cf-hls-media.sndcdn.com/playlist/50remGX1OqRY.128.mp3/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1tZWRpYS5zbmRjZG4uY29tL3BsYXlsaXN0LzUwcmVtR1gxT3FSWS4xMjgubXAzL3BsYXlsaXN0Lm0zdTgqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=Nz4jXIokS4VBJ3AB~qzud7B2lEiGOZsu~k3BAOw4MdaT3Vqpq2wFoN9Nj5adjhPziclvTCitiro7oAYgHx-T6sKoUkgXXaanrhpUnmtnSWKSGHMIcGRjZD5~WnN9jc3VXt7kC1-1UMR3eiCgsNs~~iZSdr0EOk-W6IIJZ-XdIHJFekpcf3tt56uyoyicFfgRndjfbB9qijp3w1JVbNrAWL0oOHjk-76zspjytDQkunxtcT1cVd5VC1FiLd1azwX9bWkCHsb4Kk2sE0RRhycN7FePoG1FQysuN8deZ17NYD0CVi6QaHYzoQKrARODt1J-o0xAZWTbiwSobWcyZVc2ug__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "hls_mp3_128", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "hls_mp3_128 - audio only" + }, + { + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" + } + ], + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {}, + "playlist": null, + "playlist_index": null, + "thumbnail": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "display_id": "223644255", + "fulltitle": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "duration_string": "3:49", + "upload_date": "20150913", + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694798244, + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" +} diff --git a/tests/components/media_extractor/fixtures/soundcloud_result_bestaudio.json b/tests/components/media_extractor/fixtures/soundcloud_result_bestaudio.json new file mode 100644 index 00000000000..4f2611d25fe --- /dev/null +++ b/tests/components/media_extractor/fixtures/soundcloud_result_bestaudio.json @@ -0,0 +1,192 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16, + "resolution": "16x16" + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20, + "resolution": "20x20" + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32, + "resolution": "32x32" + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47, + "resolution": "47x47" + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67, + "resolution": "67x67" + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100, + "resolution": "100x100" + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300, + "resolution": "300x300" + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400, + "resolution": "400x400" + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500, + "resolution": "500x500" + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 290870, + "like_count": 3342, + "comment_count": 14, + "repost_count": 60, + "genre": "Brutto", + "formats": [ + { + "url": "https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTQ3OTkxNzl9fX1dfQ__&Signature=RwhiR-Mxl364C~ElpyNWLOwq1zkMdy8koxJB09jy6BxU0YAFlRQb4vB34s6gMN7ycK7ubC7kDOyJ5TAoXu8M4Jtxh8zkAmhy4RFwclsrquliRmszQBPyMXYTdsNa~JJCydEEUlSmxUCGxZZXtXWvKLDBkqcz5PAlFRQZFKnow3xJleM~Oy6sYkRvq6YH3G3sR4svUdU6V8582QpnLqB0BZp3xtcNaHFQQutpneIWzULhSKp65iGZIKL2d9xCB5PF4YUSQwXGfec6O~6G63HN~lGwq5HOWZm2jN87d4Q30QnETh3FThcf5~TomYcEzV1hKqBFneRs8jRhOkdExiCdWg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "opus", + "abr": 64, + "format_id": "hls_opus_64", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "opus", + "video_ext": "none", + "vbr": 0, + "tbr": 64, + "format": "hls_opus_64 - audio only" + }, + { + "url": "https://cf-hls-media.sndcdn.com/playlist/50remGX1OqRY.128.mp3/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1tZWRpYS5zbmRjZG4uY29tL3BsYXlsaXN0LzUwcmVtR1gxT3FSWS4xMjgubXAzL3BsYXlsaXN0Lm0zdTgqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=OZGdUNyVgOztaOWLoe8FPCDNtLrAmQK8nNfecpnMReiO3bsRPTL8bD7E1nVOfXMYPB4MD-lHDFtWM4nJenCmi6ctyHI-H48A9ELM2-bDbLuD2I6cgweJ5xUSVKFpS8CmWHIgAhVXycUYiWD9cqgf4-EsVNgJr41vIFGmw1RJZsKcC3zC3xxg6enb4fJZ0Q~vwNjUoMb3gBaIsEC-Hoy5LRZC5kp1ro8kLKH-Yi~9i2nYkqIZkDqpt7PrIKP379MYexxsmXWOUeL0iRXZ93qM10YHxOS09d22o~kVaUQx0MDRZgrm8ku7gV~tmAN77JmZ9cnDAuKdh6vHwzVVOTdqCg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "hls_mp3_128", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "hls_mp3_128 - audio only" + }, + { + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" + } + ], + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {}, + "playlist": null, + "playlist_index": null, + "thumbnail": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "display_id": "223644255", + "fulltitle": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "duration_string": "3:49", + "upload_date": "20150913", + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694798829, + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp3", + "video_ext": "none", + "vbr": 0, + "tbr": 128, + "format": "http_mp3_128 - audio only" +} diff --git a/tests/components/media_extractor/fixtures/youtube_1_info.json b/tests/components/media_extractor/fixtures/youtube_1_info.json new file mode 100644 index 00000000000..2113c70bf93 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_1_info.json @@ -0,0 +1,1430 @@ +{ + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "formats": [ + { + "asr": null, + "filesize": 80166145, + "format_id": "137", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 3024.566, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=137&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=80166145&dur=212.040&lmt=1694045208995966&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgMOPEGU85HZaKo3xJCOnGrSM_eYum5wQ2JWv_sIqPT-QCIQD9iSTPNKGEuSHLmMyeEJTRd10XJV5FCxSp6OkKsy6Aag%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 40874930, + "format_id": "248", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1542.159, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFrJLcTooT0PMPfSpXzGL9kcjwMeBinMoDzZUYMu1-zICIBq1ZEX1kBebvc9X3WJVXLTkGQjPirzd0haLxQBu3RpP&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 16780212, + "format_id": "136", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 633.096, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=136&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=16780212&dur=212.040&lmt=1694045071129751&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAORMEnAszaneCnCkbv7EYGZCGkwdFPz9AVtiaenajNjOAiEAu7pr7y9BmQj4TKgJweT6Iamv2GFyiH9tGrxdBHLySZ4%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 15359727, + "format_id": "247", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 579.502, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=247&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=15359727&dur=212.040&lmt=1694043486219683&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPqu9CfJRKuiXZ-9aQM7HGlnOb7KffCvaBihabKlWCwQAiBtvX1-H3yowRj7nXXF1Mh1Kq4Xlad0rSE3iloo-GIALQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 8683274, + "format_id": "135", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 327.608, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=135&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=8683274&dur=212.040&lmt=1694045045723793&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgDIYxFGErWArarngsKDSmFORBXj5VuIZP6M25rjY03noCIGtdROP2F5ackkfTy1jzfWSOP5miZyVR7Ha7Nye-1p2y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 10927666, + "format_id": "244", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 412.286, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=244&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=10927666&dur=212.040&lmt=1694043369037289&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKmTgCM6pbpWigpYWyAuJowoktDFAZ2oDv88cyWXX6eGAiEAkCdcjO3DqmoE6cgwGNAjM-QzC03BO3eb5CNlyADCmSo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 5678772, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 214.252, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=134&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5678772&dur=212.040&lmt=1694045013473544&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMKDYtO5SAdolqwixSH_eyzJ8ooPULtSs-zuBzJmGHsjAiAnG_Ywr9JID7K3Ocn2x0TNTTK0RhSDs6ZQOfcpuFiH4w%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 6902164, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 260.409, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=243&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=6902164&dur=212.040&lmt=1694043349554753&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAItvMxp96X6C1yfrVoGAZqmfo3bTCrVFKdChCEWVXV-IAiEAsDW5gJjGupba7Z-Ww2HyoOIn7kNTlsSA6DF0hp9WWto%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 3019976, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 113.939, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=133&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3019976&dur=212.040&lmt=1694045014258984&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgexQ8jEkRYdHBLhxX3FIVdj65lF_lGYGmyeX7GVOpoqACIQDzUOtwajoE2kMHTO07xg2XZCM1SKbnd3fEvdEFn0TsFQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 4021173, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 151.713, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=242&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=4021173&dur=212.040&lmt=1694043379783601&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgG1yThWV_AtuBmQUcjdVuVyTRxyTMtieIJSF0RyDqPakCICerLreGPgSn_5VDDGOTV7vr8FZhXNa0PEvQ08FM2VXe&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 1863601, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 70.311, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=160&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1863601&dur=212.040&lmt=1694045032286738&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKbBRlNNEf_VxL4bIwHKGABHTgFydGk3cmV02QYjnxl_AiEAxhb6sQymJuRboWP9y4qjEFJTb7gY3WuUbbROgX9Bzjg%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 2404581, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 90.721, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=278&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=2404581&dur=212.040&lmt=1694043382822868&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgTJitjV0o5vvx0y-66FPcfksjQTVxjfYtdvuVPiwkIDsCIHXOAjhGi9WB8_Wg_6_UUhdfDTr8Bp5z4TrwipILaFAe&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 1294944, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 48.823, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=139&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=1294944&dur=212.183&lmt=1694042119353699&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhALhCksAaeDWa-W1taV5xDEjH3oA797nWmbv-PqF8pXjIAiEAsPD5sd96HyHAuWzCaIx7kjL4H3JF3XGA9VSrCSkJiZk%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": 3433514, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.51, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805362&ei=ElkEZZexM5LZx_AP96OLyAo&ip=45.93.75.130&id=o-AFu4BNKMyT_lNrK_5ZvyErJEiYG7Ph8XYcCU7y4k3_R0&itag=140&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=3433514&dur=212.091&lmt=1694042124987733&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350017&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAI1XaGcNaIr4WRSRuXP6Vyb_qIBD1K8XEe3bv0IEvE34AiByotJVBUYdJggV8Jr3U5Rzmea-qNEAtWtDQ7sk6I1SoA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAOmpt4JjYheZ74B5RrWD2NtuaXZ0Cc91wENOUC5FPpMaAiBOtouFd-bPf0fUCivtANHwRrUQOag_yM5njqHC6WqWDw%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 2086732, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 6, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 78.693, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=17&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=2086732&dur=212.137&lmt=1694042486266781&mt=1694783390&fvip=1&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgejYciSzMf3NwAnwW2epslP4HDY-03REZ0sIZpK0Jgv0CIQCSayE6q9noo1UcYTu6Ur92zHs3K8kDd5s-lCQAZA8qpw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 176, + "language": "en", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 343.32, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=18&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045104514388&mt=1694783390&fvip=1&fexp=24007246&beids=24350018&c=ANDROID&txp=4538434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIgRpujqnXsjuudRv0L6VKp87f6UHmJDZA30XmFMu277f8CIQCoJicYrPGhIB5PP13IDUbyg8lOJV-ZBgCjNfy3zcH5gA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 640, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": null, + "format_id": "22", + "format_note": "720p", + "source_preference": -5, + "fps": 25, + "audio_channels": 2, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 762.182, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=1&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFpTkY1_S5uZCvTafPGCDGCcPiL6EHdfSnQtQzLWydsUCIAhEAOrTtNv4xp17n3S5Ze-IiwIwhEf-zihvjo7nVvfu&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 1280, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.64001F", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 31265835, + "format_id": "399", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1179.62, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=399&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=31265835&dur=212.040&lmt=1694042163788395&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUbywY0klHGizOIyhJ3cguHzwvQmw-Rmh84UDJogIWs4CIE8WUOnDhJ1z04KuH59Rw8Voa52o6qeEi4l9xadR1NU1&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.08M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 17466721, + "format_id": "398", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 658.997, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=398&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=17466721&dur=212.040&lmt=1694042319819525&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJw5NF8ZwVqI5BMpvoJNn9lSZISS82fTS8fK-iq0GuL4AiEA1YylFyrP3-Gr4lmzxCLvdyKGhA9hf1um15HiYUjnlYc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.05M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 9434981, + "format_id": "397", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 355.969, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=397&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=9434981&dur=212.040&lmt=1694042458043976&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAPHd2oyaIGrH0mpo37I0thAhDWpEEnOKezaYAgmzmbgeAiEAtFY_9vW2BLjFw1K5iGjpKxWgHD7EaJ8Fes90UrJ5mRQ%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.04M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 5438397, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 205.183, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=396&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5438397&dur=212.040&lmt=1694042190822892&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAING9Ei_VhHF4Y-fcYjWEQojyteALIiVytzMGWi7IcEpAiEAi_XGT5o25-YUF-1klhXqPsEFYTw-rW63_RnvPFzSk7Q%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 3024455, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 114.108, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=395&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3024455&dur=212.040&lmt=1694042297309821&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAO80OlDOCpZh86wM0tBC0jbbcSSbOtmmTdRE9Fgn_rCuAiBI6VyPO_cUf3-xsQhHoaWTSJHrWniVyaWwVDQDGBHyIQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 1416915, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 53.458, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=394&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1416915&dur=212.040&lmt=1694042192787352&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOv8_J7S5rrFISKrjmJrUNMZioocAJ6Q-eZmonQQMMz8AiB9pJRRZqY8dqrOkRIVlc5zizOqTwH1vSDmfPn2DGpKqg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 847252, + "format_id": "597", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 31.959, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=597&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=847252&dur=212.080&lmt=1694042194934376&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgPvtFHtAXWb8KFJX-R_tiWq4YW5fi5qpOphV64886bxACIQC3aIhifdH4RX1vAeiel_Z-TPaf1mYPguvMcGW8YyWW2g%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4d400b", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 643306, + "format_id": "598", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 24.266, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=598&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=643306&dur=212.080&lmt=1694042224218554&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgEL1hPDU89-fPzWMdN7U6tfY5AnhcS5RHoBsE5tppfUICIQC5nXz7WdNLEUBBQKTFmpFK6MIUGi5cINnVC6rJgeTnBQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp9", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 1232413, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 46.492, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=249&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1232413&dur=212.061&lmt=1694040798737498&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhANQc-4QonHhfMx6V4o3i3BLIHfzm66cVVKlwoquqHidOAiBPQR9Fbc9dWfjXbn0evYgCoBT3AwPQOpujWrLg4zFI4Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 1630086, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 61.494, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=250&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1630086&dur=212.061&lmt=1694040798724510&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgQvZ4OBBU-FdMxuoPJaqUPUs-7dvksMlD-VN2RPslh34CIQCVlEDiWcrdBh1WZGv3FGgER1H2M1T1k2l-ZuIPo9-tFA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIge5I_9guABrA2XK3v53t6qBEjTmV3US8iB_qYhyR07r8CIEcFZGoFipdzcpBQcwRioilKBeAmkWGbmk8vbQnpH9Wu&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 817805, + "format_id": "599", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 30.833, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=599&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=817805&dur=212.183&lmt=1694040788792847&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgQa2KnHJ7Ie9aaTgWhEfZvOPky4QlVeQsVU0FuCkn5UsCIQDq7U0M6T5NZGQG4oP5RbMza_qKIxhlGIG5wHfEK7ZiUw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 832823, + "format_id": "600", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 31.418, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805363&ei=ElkEZezvO728x_APitqs0AM&ip=45.93.75.130&id=o-AIoAwIqqGXNpDKZy--HxgHnTKqNbNz-9C3MhXB5_meSb&itag=600&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&spc=UWF9fyaRvt95zG_QhX4TgA1XVl50FVc&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=832823&dur=212.061&lmt=1694040798740210&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgHHt5PanEXai9aHtyNSlGDogA6tj1cB5VwIOTqOkZZ3MCIAjVYpaCCY3o5S4qj4bZrJWrih4KdDKQRGbw9VPjiMtZ&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgCvLPKwYSnPZU87UPJZEH5fQLdcMySGRjdYAi7jq4mFMCIHLypxiXjTidMar1y-WOwfrRV5yEaxdVUXxi37AgwJ8m", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D1294944%3Bdur%3D212.183%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1694042119353699/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRAIgTgioY5fxCkE2NdBAjvi2kGOL1WVJmc8TnDAVt_s6EioCIEEK5SCvGzw4AVyBfMsWOOWKNjkhAsyCCIIsOLDk9Ugy/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAMtTLkSF6ZAPLvpmJC_zjUQKWG1YqdXwzx-Hb3aTGoMIAiBKNjlY2LAcyjGZPAnGr89NJcy5vnt3TUBCpsAgV4COvA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1 + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D3433514%3Bdur%3D212.091%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1694042124987733/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRAIgf5KpxsheUB1O3WaKDC2wo4WvlS-OfjBedxkxdt5c56kCICE--f5TvdD0Wvl2gOoDnK6OOm9o9sT-bE2e5bL3pmzk/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAJon-w6LV79CUBJyxy_N90noc1adtF4n1KtD-pe0ICWHAiEAy4WfwioZ6IMguDYrsBXRcrlciXn62hYjLZXrKipa6Ww%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1 + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D3019976%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1694045014258984/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgBPp-j3Am-0iYgXFKXfIAH5eijde-HM3DFKl0OpR70fQCIQC4gaiGlRo72lHx95Qa6RtvgLaciJ-AkEGV6YNE6C-6EA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgXlTuT2hnBPXSwoG_OJwagXNWzIicPv66RpnHUv8nlV4CIQDoBxfnk2ipYnO4mna6vM8_lNPLbbAz09bpnypMw7IJfg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 225.675, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D5678772%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1694045013473544/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAMg_Gnt-OwH-T3pCVW0FsgqyJmVWuiVW8GJGUiqWHYeyAiEAt1fzIQQLStsy7XfhrSbm_ifEyzca_iWDv4XNnCcPkz4%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAI70qWT2nrAHNiZzsSnZCqmgIZ5SVxhRp-46G8uv2deiAiEAjLhJMl-rePyimvtvkp9cJKzz2oBoLr_XRyRaTxRY63k%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 478.155, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "231", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/231/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D8683274%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D135%3Blmt%3D1694045045723793/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAIEiEzed0ARbUiLpSVQq2JrAl-q5mt5Z8Ory3CX19IfzAiEA5l5ODUGsOvLdQSkap4HUHQG8G-tU1BbxFEJn7mCboDM%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhANyW25dIhGefJpers3uURRu0BoZud5MdJ3nuHhivJDj-AiEA-YkoifzSrzmDrVWcww4WSfVg4rlYX31KubnOw33qLHI%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 660.067, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "232", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/232/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D16780212%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D136%3Blmt%3D1694045071129751/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgW8oXWkXJ2CpdS-SQ89WP-DQs8ZTV_av7rLHG3JdzjNQCIQC-qAtsOiN6HNeEKtL8I5zAEkLBN6PcVq-8YcJ0aatUfQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgRRfTc2NK_HV_O-8B0sJrB-dcriuq568Pw0dNWyyejKwCIQD4e2cm121hNtCJWfUY6XvUaaf7FhhSFs61YTYV2fsu8Q%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 1130.986, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D1863601%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1694045032286738/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAIuQOqRrgPiUALERfYTPnvgiiEl3Y-hWhEShbnuYBUMzAiEAl9vW3lXvvaLtKHu65US4JiIR73cGeQJ1xuV7adBjba8%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgQ56vCVM4B829mI4KWbI5O-HwEKsSMYue1IlTqpvhQboCIQDZwEj4aLMcBWW02pA7Gme8m_vi4AWNwU0MfZa_ic0DeA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 156.229, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "270", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/270/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D80166145%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1694045208995966/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgdZEL9m8spiRdfZ6B6ne8zjlERFmico8b_LZjVogCmQcCIFxn2zREZ79Dvu8RwzwxxbL0NvP_ewKv76B6NQScIokV/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALgcxz9T7FC0kNsu8aExdpu8tZP4qoz_2G--MUaFrqWJAiEA8n_0Yiiheef6zxz8XSmwn6nK6BITNANAEYawSdF0K3w%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 4901.412, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "602", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/602/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D643306%3Bdur%3D212.080%3Bgir%3Dyes%3Bitag%3D598%3Blmt%3D1694042224218554/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgL4aGnMnSrGf6vcwDgtXpRKZxZsLCjySttx-s_2dnfoYCIQDQn0D8-97DZUKapbxrdeYy7oZlks8zJEFye4KNgQbpMg%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhANgsfOucjYXGWNMiFEgNFKxXBHxNM9wFHwyVPM_wreSZAiEAmw9jK9DAyv2E6QEK0Vm0EJZjvR-A14iGJmSC_JKy_ws%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 80.559, + "ext": "mp4", + "fps": 13.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.10.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D2404581%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1694043382822868/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgfvzlhM8zg37LyLxQKYy2Cig_-bXHv87hgT7ZUPjDg54CIDYJTre0xGJN6_b5MRQ2LuKqLwaDgU5vkYkZW6BHFfXv/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAN0dBMwvH-jcwOsraHVWtL3jOO6FOXFcdHXX48KHsCV1AiBm1Jzw95WAd4BokAutYwe3vT_TY5Eest1rsVv8yGMOyA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 153.593, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D4021173%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1694043379783601/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgNVq2A16AB11bSRbF-VemrxBW4D6CkHAA-Wvl3ViEVNECIQCOMuolJU8sVWl162GCh6n9hZj-JW2pxqSxa0TpsRYM3g%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgaVoQDSjlOt4m5mYFjH7OJbwy84r-osKOjTCpYB_Gx_MCIHs2KeHk3sv9lxHxI4i4IsfRyxPBu26h31LriFeRbMAV/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 287.523, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D6902164%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1694043349554753/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAPb_cJ7B9AWOarBtYrgfZTlfDHLwhd6mu1QC4PgVIQyLAiEA93DFwmoqw9mU7dn9WpJEeG6HfF3x9Xf9PsG8MWc3raU%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgBAxC-SrAMNOq_PWmlx6YltmI8nhhxuA8CDLipHDvvPMCIQDBPC4LupVtVtqWpzUrK6UfupUzx8xLKeH5bGjpojVvvA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 566.25, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "606", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/606/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D10927666%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D244%3Blmt%3D1694043369037289/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAPU2v3Ok8qhoofneHVY_YV_koEBOJgLURmXWcgTJ0tw0AiAFlyDgETgGfE9b4Tk7Ab7dtm5D_B16mg_7Sc8iZ0KS_w%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALcSQ4fc6rlNhZEgjrtUAjk1kdEzAAbVWRURDoVcxxQDAiEAj4z31_nBsmweD2oOTcJhboJNhw_DFmuicu0Cc9JIsyQ%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 733.359, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "609", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/609/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D15359727%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D247%3Blmt%3D1694043486219683/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAKhwT51CJqsjlys2ysTyc2QjNQ5NKJmlovv2hNKgDCdiAiA-vB8aDDkzRk69H2wRc9Au3y6RIfrJseFe0TIelfurCA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAPQ5kYjGqqafmkJoinmu4jJU9q8Fsp5wD-F9sMc5q-V2AiA3vmkk88cjfMOGS5AA6Qt0JA7nxQmxygSptXXEMgpirw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 1179.472, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "614", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/614/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D40874930%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D248%3Blmt%3D1694044655610179/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgF-wK8LF-VDQhzk-7oDYPXASBvjmReT4SKM8MyTdmkwYCIQCJxCIqe4q3Ve5uVUUzoylaiO7HRqlU0WZ223DnQh5_Sw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgGJz64zi14yiboHxiEB6gBWhlI04VkI7O0KApaWHeypgCIAfWBaJu4kV4KXbOl_DxAU6XCktBqc_AufdooMpB-4rx/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 2831.123, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgL_3FtQ7DkyARhrpt_ESGW00JCz56g4BYAMMj6qvVFesCICWtjOG1-0QgTDrdNc7wjVKwF8V2I4v4Mp_wZ3djA4ub/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgOHh6fExUbXZij78OmPTmIw5L1q7m811e-sFdMZuhpMsCIQDPPC_375zN4QNsIHe8Zf9Wf_Gu14fVDDSg6RxbN4VeBg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805362/ei/ElkEZZexM5LZx_AP96OLyAo/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24453782/txs/24453782%2C24453783/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAMZ9OqWiYzU3OCk_pfIzyc0wMoEAyof6gjPHfXfA31TWAiBfhDxLaKc_GfsbOXFKYNQWzXk7O2iEdkXs94yKorsTEA%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgQchl8t_Unu2llA54YYtsc13C3-rAR0DSf_dbfklXOekCIC68o3Rie3AUegGkI52bX-Nav8hcyTxaP05T2nc9EJmD/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": 99, + "format_note": "Premium" + }, + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "width": 160, + "height": 90, + "fps": 0.5094339622641509, + "rows": 5, + "columns": 5, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M2.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M3.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M4.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 15.703703703703724 + } + ] + }, + { + "format_id": "sb1", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "width": 80, + "height": 45, + "fps": 0.5094339622641509, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 196.29629629629628 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 15.703703703703724 + } + ] + }, + { + "format_id": "sb2", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "width": 48, + "height": 27, + "fps": 0.4716981132075472, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "duration": 212.0 + } + ] + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/default.webp", + "height": 90, + "width": 120, + "preference": -12 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDd2KtelLHaNSXrI9_5K-NvTscKNw", + "height": 94, + "width": 168, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBUpEOOWUXWkNyijQuZ4UPzp2BE-w", + "height": 110, + "width": 196, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBCyhr8AqpJ1SxKVU6SyK5ODJ_IpA", + "height": 138, + "width": 246, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_p0PncTtkrhaNDZtntrE3gKkoYw", + "height": 188, + "width": 336, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq720.webp", + "preference": -2 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg", + "preference": -3 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/0.webp", + "preference": -8 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/0.jpg", + "preference": -9 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg", + "preference": -13 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd1.webp", + "preference": -14 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd1.jpg", + "preference": -15 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd2.webp", + "preference": -16 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd2.jpg", + "preference": -17 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd3.webp", + "preference": -18 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd3.jpg", + "preference": -19 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq1.webp", + "preference": -20 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq1.jpg", + "preference": -21 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq2.webp", + "preference": -22 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq2.jpg", + "preference": -23 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq3.webp", + "preference": -24 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq3.jpg", + "preference": -25 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq1.webp", + "preference": -26 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq1.jpg", + "preference": -27 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq2.webp", + "preference": -28 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq2.jpg", + "preference": -29 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq3.webp", + "preference": -30 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq3.jpg", + "preference": -31 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/1.webp", + "preference": -32 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/1.jpg", + "preference": -33 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/2.webp", + "preference": -34 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/2.jpg", + "preference": -35 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/3.webp", + "preference": -36 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/3.jpg", + "preference": -37 + } + ], + "thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "description": "The official video for \u201cNever Gonna Give You Up\u201d by Rick Astley\n\n\u2018Hold Me In Your Arms\u2019 \u2013 deluxe blue vinyl, 2CD and digital deluxe out 12th May 2023 Pre-order here \u2013 https://rick-astley.lnk.to/HMIYA2023ID\n\n\u201cNever Gonna Give You Up\u201d was a global smash on its release in July 1987, topping the charts in 25 countries including Rick\u2019s native UK and the US Billboard Hot 100. It also won the Brit Award for Best single in 1988. Stock Aitken and Waterman wrote and produced the track which was the lead-off single and lead track from Rick\u2019s debut LP \u201cWhenever You Need Somebody\u201d. The album was itself a UK number one and would go on to sell over 15 million copies worldwide.\n\nThe legendary video was directed by Simon West \u2013 who later went on to make Hollywood blockbusters such as Con Air, Lara Croft \u2013 Tomb Raider and The Expendables 2. The video passed the 1bn YouTube views milestone on 28 July 2021.\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/YTSubID\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/FBFollowID \nTwitter: https://RickAstley.lnk.to/TwitterID \nInstagram: https://RickAstley.lnk.to/InstagramID \nWebsite: https://RickAstley.lnk.to/storeID \nTikTok: https://RickAstley.lnk.to/TikTokID\n\nListen to Rick Astley:\nSpotify: https://RickAstley.lnk.to/SpotifyID \nApple Music: https://RickAstley.lnk.to/AppleMusicID \nAmazon Music: https://RickAstley.lnk.to/AmazonMusicID \nDeezer: https://RickAstley.lnk.to/DeezerID \n\nLyrics:\nWe\u2019re no strangers to love\nYou know the rules and so do I\nA full commitment\u2019s what I\u2019m thinking of\nYou wouldn\u2019t get this from any other guy\n\nI just wanna tell you how I\u2019m feeling\nGotta make you understand\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\nWe\u2019ve known each other for so long\nYour heart\u2019s been aching but you\u2019re too shy to say it\nInside we both know what\u2019s been going on\nWe know the game and we\u2019re gonna play it\n\nAnd if you ask me how I\u2019m feeling\nDon\u2019t tell me you\u2019re too blind to see\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\n#RickAstley #NeverGonnaGiveYouUp #WheneverYouNeedSomebody #OfficialMusicVideo", + "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", + "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw", + "duration": 212, + "view_count": 1447363306, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "categories": ["Music"], + "tags": [ + "rick astley", + "Never Gonna Give You Up", + "nggyu", + "never gonna give you up lyrics", + "rick rolled", + "Rick Roll", + "rick astley official", + "rickrolled", + "Fortnite song", + "Fortnite event", + "Fortnite dance", + "fortnite never gonna give you up", + "rick roll", + "rickrolling", + "rick rolling", + "never gonna give you up", + "80s music", + "rick astley new", + "animated video", + "rickroll", + "meme songs", + "never gonna give u up lyrics", + "Rick Astley 2022", + "never gonna let you down", + "animated", + "rick rolls 2022", + "never gonna give you up karaoke" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 2200000, + "chapters": null, + "heatmap": [], + "like_count": 16843107, + "channel": "Rick Astley", + "channel_follower_count": 3870000, + "channel_is_verified": true, + "uploader": "Rick Astley", + "uploader_id": "@RickAstleyYT", + "uploader_url": "https://www.youtube.com/@RickAstleyYT", + "upload_date": "20091025", + "availability": "public", + "__post_extractor": null, + "original_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube" +} diff --git a/tests/components/media_extractor/fixtures/youtube_1_result.json b/tests/components/media_extractor/fixtures/youtube_1_result.json new file mode 100644 index 00000000000..0e45ef236fd --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_1_result.json @@ -0,0 +1,2264 @@ +{ + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "formats": [ + { + "format_id": "sb2", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "width": 48, + "height": 27, + "fps": 0.4716981132075472, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "duration": 212.0 + } + ], + "resolution": "48x27", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb2 - 48x27 (storyboard)" + }, + { + "format_id": "sb1", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "width": 80, + "height": 45, + "fps": 0.5094339622641509, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 196.29629629629628 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 15.703703703703724 + } + ], + "resolution": "80x45", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb1 - 80x45 (storyboard)" + }, + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "width": 160, + "height": 90, + "fps": 0.5094339622641509, + "rows": 5, + "columns": 5, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M2.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M3.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M4.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 15.703703703703724 + } + ], + "resolution": "160x90", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb0 - 160x90 (storyboard)" + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D1294944%3Bdur%3D212.183%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1694042119353699/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIgD1CuHxyBZDp8CotqpDW-OXWwl5inwtPybJWCFn-qy74CIQDKHqzxkxy3eUBhpBGBJlFCka68OmvIx_jyzzZdwHS_cw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAJgReJ36jZBrK0vcRptTmPDXo5S_7sQH9MjX4TZ93-PBAiBzFPY7QeldoOL28TLyfUfHD7-ehZvD8QZsmG0QV3S2dQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "233 - audio only (Default)" + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D3433514%3Bdur%3D212.091%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1694042124987733/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIhAKKU8khD3yI3hS2p11CClk471LS1rcvDpkUkx-M8bGsXAiA5WQCNqjcSerErypAdI8V2e4piquAdGgphaj81Gv5Y_g%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALcHmGhK_PjVviKLKPZdftQH-9u5ESmIwSU9N_77HqKTAiEApoSoPuw-BXgfaToSnaFFujfVaKOazoJsnqSA0PzmMuk%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "234 - audio only (Default)" + }, + { + "asr": 22050, + "filesize": 817805, + "format_id": "599", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 30.833, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=599&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=817805&dur=212.183&lmt=1694040788792847&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhANrHfEiq-FeLKdAwJdMHQPDWTfAJA0rrz0xPoLvcW6FnAiEA1AtB0TyhtPL65Yh_vFDsWcbaQuqaMlnsMFvlM3p12NI%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 30.833, + "format": "599 - audio only (ultralow)" + }, + { + "asr": 48000, + "filesize": 832823, + "format_id": "600", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 31.418, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=600&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=832823&dur=212.061&lmt=1694040798740210&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMi0Gtku1QTmYFAGzEAvQnHrH-wNLXK_sblRAkQs6GKzAiAozCGsX0WxazgHTgUX2o_bziMG_TTIKBTisdTCIHdPbQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 31.418, + "format": "600 - audio only (ultralow)" + }, + { + "asr": 22050, + "filesize": 1294944, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 48.823, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=139&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=1294944&dur=212.183&lmt=1694042119353699&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPwnDwj3V76nkrC5Ei6len9NHl7IHCSKu0J8T1KgImFDAiBHJODRlt5yaelGkhfXiSwCFkg2QxtvPY53tG6XS6X2lQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 48.823, + "format": "139 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 1232413, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 46.492, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=249&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1232413&dur=212.061&lmt=1694040798737498&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKdKRZBqXKnZA3vtezvG0nXkCWj-w1Y_aRo1mn3_owXiAiBj6UPDRERpzv7YbihbiK60bWG1aEgDooK9YfY89qhxew%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 46.492, + "format": "249 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 1630086, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 61.494, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=250&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1630086&dur=212.061&lmt=1694040798724510&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgSbDvX7rFBu2kVQco2hdtzDWyi3YZ2VUKzJyUaSY7be8CIG5771IIeWcW8RdAAN0JxTBH643YnjiAA07Vz7CNxx_B&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 61.494, + "format": "250 - audio only (low)" + }, + { + "asr": 44100, + "filesize": 3433514, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.51, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=140&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=3433514&dur=212.091&lmt=1694042124987733&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgVTJv_gHCMf1uIjkGLTyUU-viSD3y2KYQdGNTciMiBEoCIQCAdN10CdXuAHvoTXfN6_Gv4Lzw4I0QlQ8ERgaT0hB7FA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 129.51, + "format": "140 - audio only (medium)" + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAK3VvF4KH-4nYQUP1gpSURVLxA9j_1qSnMFHt4a8Stk8AiEA8wi7_ubVv4HzCGjW_pWZaUBRNXJaQ-1GuAAJovlF_E8%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 129.689, + "format": "251 - audio only (medium)" + }, + { + "asr": 22050, + "filesize": 2086732, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 6, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 78.693, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=17&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=2086732&dur=212.137&lmt=1694042486266781&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOkaEqDoIIOiYp0heUx8lnvZolT-wzM9zqxL-tgwnFK5AiBzG-hPT4NtSPfxog-4CwBC6LaXrfz49WBhD8iqDnlN5A%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 176, + "language": "en", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "176x144", + "aspect_ratio": 1.22, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "3gp", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "17 - 176x144 (144p)" + }, + { + "asr": null, + "filesize": 847252, + "format_id": "597", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 31.959, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=597&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=847252&dur=212.080&lmt=1694042194934376&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgNkcgiUaSHK0nPL5Cy1o8cF295-sKrfhj7AtTWCJmze4CIDzO5qZyAfetWP-eepABZ_tWuL90Cy4wxuxBUDkcuXnR&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4d400b", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 31.959, + "format": "597 - 256x144 (144p)" + }, + { + "format_id": "602", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/602/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D643306%3Bdur%3D212.080%3Bgir%3Dyes%3Bitag%3D598%3Blmt%3D1694042224218554/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgeLKHue4a_2TPd76zNOTbuLKfMAVtqRZbb2lulEwZ7fECIHj-QkveXvv85Ctu_k8gsJFs-Fsj40qCgnIAVkEPTFwU/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAMQXMw7AiQm_32IU9e2_Ir9-CFuFyo0e2jHKHKsI8I62AiB1f8VVq68s__fU5_nAOkDRkJgFTElTGsrPiqjNqZF_Eg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 80.559, + "ext": "mp4", + "fps": 13.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.10.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 80.559, + "format": "602 - 256x144" + }, + { + "asr": null, + "filesize": 643306, + "format_id": "598", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 24.266, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=598&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=643306&dur=212.080&lmt=1694042224218554&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgK9K_H5gF-Qb02XsA2LewB3Er1g1XcSPuYUFHZQckCScCIQC3_wB_AA-O6yawRHg0wqvvbkQIuOCaVBKg5BvyYGpRQQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp9", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 24.266, + "format": "598 - 256x144 (144p)" + }, + { + "asr": null, + "filesize": 1416915, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 53.458, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=394&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1416915&dur=212.040&lmt=1694042192787352&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMQ71tBK-Dy6-3rbL2tmDLlurujtt24coHDnxECGNxdAAiEAnU81MW518svYPnihd9Rtyxy-IXDbaGLTVf-P0bVJ2tU%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 53.458, + "format": "394 - 256x144 (144p)" + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D1863601%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1694045032286738/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgXHDG-qiMqTt9YENXjuQKu13yWrpZTLp4X58aZlLiLzECIGZbZGEiOJcTJboNbRXccVkEgyOyWeLMDh6ioim-bJ_6/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAKk-D7JZ7RcOE3suuD31f7wxlRQ_tJUZNGL0A3YnHfO_AiEAoX51OvytGT03SdZAL4nKEl9WOxtBK0hPrw3zdH-Ip9I%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 156.229, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 156.229, + "format": "269 - 256x144" + }, + { + "asr": null, + "filesize": 1863601, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 70.311, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=160&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1863601&dur=212.040&lmt=1694045032286738&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAPlI72wERnjcS79xo5HcBTNPvSNOt8nm2aBbFiTVNDfLAiEAv4JfOoJbOoVFmjcYb72mKHqxy5Gs-IfwfFxOr8WGTmA%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 70.311, + "format": "160 - 256x144 (144p)" + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D2404581%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1694043382822868/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAO-F15u2InMLQc5SvMWfQu-zotQ81OmJiGMgKkddxNseAiEArLnYasxAWXcVrQ8fNQY9HAOATI6ny63HiwdzGBwKV74%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAPDKyB3Y46PlUZ20DZ9Ydx2KV8yi7vaSoTQSv8QshMyLAiEAo2Ko43q4IlTc-UjgD0biO8jwyqc6V0SklA-PHTHkp6U%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 153.593, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 153.593, + "format": "603 - 256x144" + }, + { + "asr": null, + "filesize": 2404581, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 90.721, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=278&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=2404581&dur=212.040&lmt=1694043382822868&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgR38Uy-mo-Db11DAk18HSZDAmPMZMbPgpWeI119jLmgUCIGEoJHlq7rWKW5o0Ht7Vhk7hgKOvRf20jC5pSkKY_ge6&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 90.721, + "format": "278 - 256x144 (144p)" + }, + { + "asr": null, + "filesize": 3024455, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 114.108, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=395&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3024455&dur=212.040&lmt=1694042297309821&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgQ_LnUNoK136WJWQ7NM6Ib4TTL0pE8qqbeTIQsereumACIEWmvobEcgVL1bPxIlgapDRlseS0D-1fSlalMwYCYmMX&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 114.108, + "format": "395 - 426x240 (240p)" + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D3019976%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1694045014258984/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAJgzQwyB6wO1x057WfTOaJghMLt94xgG46o_NuxA65GXAiEAhgHuPXOahAPGxpTLWR5idIWUY3cbI3q2A9iQK1OBpFA%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAJoGAsyUtnUu4BdMBNNvUhBk0LQUQEVLAmuzCoXVeUjWAiB3CVjAZiRgyvg9KGJ_1xZ45YwfrrZXsQ5NJUylUbumMQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 225.675, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 225.675, + "format": "229 - 426x240" + }, + { + "asr": null, + "filesize": 3019976, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 113.939, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=133&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3019976&dur=212.040&lmt=1694045014258984&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgHut2YjTliC05aABej0rGGmxW1jHfh2eu-MeRewXRM7MCIG8ETnFycp5DCWGD5uCr7qPN85hQ-aaBw-yrybXZgy9p&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 113.939, + "format": "133 - 426x240 (240p)" + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D4021173%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1694043379783601/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALCRyJOZVKhRnvgaO_5fKWy92t-NnatjduvAjLRiRnmMAiAVQVv4H7o06wfd3W49z18aq9eGNZHX-nIgTtYjCJyXjg%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAO6Kgomvh5PsV9sprYs2AyWgf4C7tqvLoiNvYPlEMPEZAiAeih3hs03M_P8ZTp9_3c2ciVBPwlrpHSSuFkVM_EBUxQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 287.523, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 287.523, + "format": "604 - 426x240" + }, + { + "asr": null, + "filesize": 4021173, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 151.713, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=242&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=4021173&dur=212.040&lmt=1694043379783601&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAPX_5nVW6KI5yNXbwGyo7bnTPbApTDwiH7-79IyjgvRgAiEAkfqdBKaX1oY2oJUqe20vZGcu9BXsx-XzaE87newCGJA%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 151.713, + "format": "242 - 426x240 (240p)" + }, + { + "asr": null, + "filesize": 5438397, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 205.183, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=396&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5438397&dur=212.040&lmt=1694042190822892&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMjAVWsBxXTdrkUCjetXA0wcvNC-bE2t5WvI3uLa3SdIAiAQnYlPPGBD74X4SLekLInhQ_jzMPIr_pPMU5lqDL9Fvg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 205.183, + "format": "396 - 640x360 (360p)" + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D5678772%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1694045013473544/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhANmML74fiXcJws0gePdsHe9jDX2IBE5czxPinkJ2Q06JAiBtNZxHZOno8IQuCepGulwmzeAQI7PmE3Tx-P1ANRoO4w%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhANuJDfazCLBzfi5AG_cynB3EUTZpp0qVyJANI-hqb-R8AiAzEfjByNCpNoHTIaDKZsHSU5XWa95fObhCBXh1u1NCZQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 478.155, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 478.155, + "format": "230 - 640x360" + }, + { + "asr": null, + "filesize": 5678772, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 214.252, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=134&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5678772&dur=212.040&lmt=1694045013473544&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgL3r-fvk7IAbdR0fbEsuyTumh947F-bBPYFYCsUAYHn4CIQDzBcNxo109jsHnEdACdl7Aye3dRPtc1jknzHj0EyUKXQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 214.252, + "format": "134 - 640x360 (360p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 343.32, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=18&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045104514388&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4538434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAM2plJS-gAJ4uYOwQeANCtU0-8ymKKS4UaDl-enTwOEWAiA1tNz59q2t1juBE3cn3kj-VdzXCOyGvOYj7rw6o0NOIg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 640, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "filesize_approx": 9316331, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "18 - 640x360 (360p)" + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D6902164%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1694043349554753/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgSTDnFspU-gaKZYPvYpx2EhksgYwTaiT9uT9mPViuElwCIQChAtsCDQV_zVd1EiCFRBSWCNKrXPIpwqwPUTkMtHybww%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgcrOrNSB0mGrwDdvEONEW9h8g8HdOO687OxlOVpCSSewCIQCH-5b2WZDblYcNCz7kPPZ36bakY-BFoTZQMRkMZuuX9g%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 566.25, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 566.25, + "format": "605 - 640x360" + }, + { + "asr": null, + "filesize": 6902164, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 260.409, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=243&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=6902164&dur=212.040&lmt=1694043349554753&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKMIJKvo9ZzUZut250l_Q6BHMMQhWM90KIIBveDOKYxLAiAY9lN-HIFiF6jZAHGVSLOt7YiGJuFYkl9Jr5n3dEEdCA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 260.409, + "format": "243 - 640x360 (360p)" + }, + { + "asr": null, + "filesize": 9434981, + "format_id": "397", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 355.969, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=397&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=9434981&dur=212.040&lmt=1694042458043976&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAK_5svyu86Pfo7WjTLuJUO_wtGwHiTD31K0zOmp36aPYAiEAzJTu5jjrzcyw7eP4QtBtwKl8aZimxTlFUPhhUZ-4bMc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.04M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 355.969, + "format": "397 - 854x480 (480p)" + }, + { + "format_id": "231", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/231/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D8683274%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D135%3Blmt%3D1694045045723793/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAJjhcKpL13cYSuTqOwozWtc3vVMIElJxJOfY5sS0IGV4AiEA1XXpFt7X3ChfDqtjNbjGNvgPBdqgOGzfkqJpk8usNfI%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgOcutrdh0_omv24Ahe_mRQz1KF4K9coBCJwIfi4AtHvwCIBqBIniTICvGYn1FQ9xqKDJ02XwTasAGlZy_HBlz7ncW/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 660.067, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 660.067, + "format": "231 - 854x480" + }, + { + "asr": null, + "filesize": 8683274, + "format_id": "135", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 327.608, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=135&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=8683274&dur=212.040&lmt=1694045045723793&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMKCkdyx1Vw0px4cUismXBqCq65A5fbK0rBmpiqThNPLAiEAyHm-eRCEtLG4kyQvZQiNsJ4sReqD_eNlIME_M4-GK7U%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 327.608, + "format": "135 - 854x480 (480p)" + }, + { + "format_id": "606", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/606/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D10927666%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D244%3Blmt%3D1694043369037289/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAP9jaGnwC_9w9kk5Ae8_1aGdqncL-qBi4bLOGsHxMO8PAiBo1mLmboJ6aZ5INyOPONteKvox5hDfDvtHQaHdKc79fg%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhALAdFndATrnlrnqD7xNQjai35FykQK1KB67wmvnZabTqAiA2utOfPU9i_llXfASJexiFLW0UH5trY7XQF55BrSfAYw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 733.359, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 733.359, + "format": "606 - 854x480" + }, + { + "asr": null, + "filesize": 10927666, + "format_id": "244", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 412.286, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=244&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=10927666&dur=212.040&lmt=1694043369037289&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgL0-pDtuLHsDB8OFAYjRKvfPxzxwxJ6Qm62n4yqH2U5cCIBK1W48z_Phf1Zg4mTuRFyYXmtLN_EQ-Uc62f89wxFM1&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 412.286, + "format": "244 - 854x480 (480p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "22", + "format_note": "720p", + "source_preference": -5, + "fps": 25, + "audio_channels": 2, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 762.182, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 1280, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.64001F", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "filesize_approx": 20682570, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "22 - 1280x720 (720p)" + }, + { + "asr": null, + "filesize": 17466721, + "format_id": "398", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 658.997, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=398&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=17466721&dur=212.040&lmt=1694042319819525&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOMjiH_ZvXNYVfljzXKPfmELytttCZtylxmCbAZ2C7ZJAiAXke6jgYDRTy7fPq6ED4SO_lP7U-5PbA6mg3FPHcYF_g%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.05M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 658.997, + "format": "398 - 1280x720 (720p)" + }, + { + "format_id": "232", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/232/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D16780212%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D136%3Blmt%3D1694045071129751/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgB_C4DqZzun57wSdplvOFuDDdCtqmSLzWbueqYfdgzkgCIDp_A_H9Wxm4dE1jUHl4FV1bMmarvzaxyLal_w3do31_/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgD0yRmTVna0IUM6A-Y6SdDSJt2K1yyXW1bfCtI6a5NX4CIQDky7ka6pSZ1eg3-QZ0I20aUun70hlJ3ltkFjl9jlrQUQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 1130.986, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1130.986, + "format": "232 - 1280x720" + }, + { + "asr": null, + "filesize": 16780212, + "format_id": "136", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 633.096, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=136&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=16780212&dur=212.040&lmt=1694045071129751&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgCtqAnTmQ_vnafuFtCf39bKWqmxUsES5NVLA6oZCa8FoCIQDoL2Mw4vm2X3dJ5cqimgqKgl7kKU10Lf5aITgxZYexTg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 633.096, + "format": "136 - 1280x720 (720p)" + }, + { + "format_id": "609", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/609/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D15359727%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D247%3Blmt%3D1694043486219683/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgc_VDzoquSQo37r9Zrx7xUemxDZg31Gb7gPjXT0D0LmUCIAy9SXjTLjh3dLu7i_nnpRLsBLEBVxMGrfxfZkDcxa7l/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgF994quw-ths0iLJ_bCI1ZZsDNG5k5xad4eNpG87dDzwCIDN1_u-mBatUaDeowC-Lmvy30DrlI6F3D2PWls2QAk63/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 1179.472, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1179.472, + "format": "609 - 1280x720" + }, + { + "asr": null, + "filesize": 15359727, + "format_id": "247", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 579.502, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=247&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=15359727&dur=212.040&lmt=1694043486219683&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgZdVLyU9Y44I-7r3MCYGZXLUXt5JLnmOfbbtGDiWROe8CIGNW8E_zs2cqrb2heIQAmZ-7ykm1zie4gIHtDJvjOKR_&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 579.502, + "format": "247 - 1280x720 (720p)" + }, + { + "asr": null, + "filesize": 31265835, + "format_id": "399", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1179.62, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=399&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=31265835&dur=212.040&lmt=1694042163788395&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgaP2Au7hm10GJK8rLUstgbohBMf_KAqVJQ-RV1SvEPTcCICJM1qRtPrrp0fago3OU4jfaQ4VhAva4ZtroMTsmTR_7&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.08M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1179.62, + "format": "399 - 1920x1080 (1080p)" + }, + { + "format_id": "270", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/270/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D80166145%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1694045208995966/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAM0y0sYDi_o6cMdjgIyZc4c26MejFqEjNYogAXG76vjFAiEAqHQO47HsbIdySLwNUTarth-alwesIA8Dz_MSkJGWiMY%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhANuIQTP4TOkSEdAZBezPfFFqoqLly7XJWputNoCFAhaSAiEAk4Ix-6J6ArGc1O_riDbswfSZJk3pG5LjYIDNzQ6u7h0%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 4901.412, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 4901.412, + "format": "270 - 1920x1080" + }, + { + "asr": null, + "filesize": 80166145, + "format_id": "137", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 3024.566, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=137&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=80166145&dur=212.040&lmt=1694045208995966&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgLHR5OaifEXrvnRV5vM_bxVJCDyCoTnbcD-q7gb86aJ4CIEE8XcDz57sm8-qnMgoQvn69Alel4tavulazkCswAVhj&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 3024.566, + "format": "137 - 1920x1080 (1080p)" + }, + { + "format_id": "614", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/614/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D40874930%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D248%3Blmt%3D1694044655610179/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgcC_QI0YD1K_tNrBro7SLA1_zJNvdDhjCqyZN6QnpYUQCIGfErG6d1jwWpFxNrCknBd9CIc7UZGed8OcXzoveMrWl/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhANx6s6QBPAyCEC7yZmO9ZiY4o-ZE3keVlqtm4bQePIb8AiAY07-LkxMj6o1_LCBeGKER6AsL7rYXU1K2Gy7f5jZNhw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 2831.123, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 2831.123, + "format": "614 - 1920x1080" + }, + { + "asr": null, + "filesize": 40874930, + "format_id": "248", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1542.159, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJ-5AjGgFTR1w-qObfMtwCvs07CU5OUDG7bsNqAXrZMxAiEA4pJO9wj-ZQTqFHg5OP2_XZIJbog8NvY8BVSwENMwJfM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 1542.159, + "format": "248 - 1920x1080 (1080p)" + }, + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": 99, + "format_note": "Premium", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 5704.254, + "format": "616 - 1920x1080 (Premium)" + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11, + "id": "26", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDd2KtelLHaNSXrI9_5K-NvTscKNw", + "height": 94, + "width": 168, + "preference": -7, + "id": "30", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBUpEOOWUXWkNyijQuZ4UPzp2BE-w", + "height": 110, + "width": 196, + "preference": -7, + "id": "31", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBCyhr8AqpJ1SxKVU6SyK5ODJ_IpA", + "height": 138, + "width": 246, + "preference": -7, + "id": "32", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_p0PncTtkrhaNDZtntrE3gKkoYw", + "height": 188, + "width": 336, + "preference": -7, + "id": "33", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7, + "id": "34", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5, + "id": "36", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "description": "The official video for \u201cNever Gonna Give You Up\u201d by Rick Astley\n\n\u2018Hold Me In Your Arms\u2019 \u2013 deluxe blue vinyl, 2CD and digital deluxe out 12th May 2023 Pre-order here \u2013 https://rick-astley.lnk.to/HMIYA2023ID\n\n\u201cNever Gonna Give You Up\u201d was a global smash on its release in July 1987, topping the charts in 25 countries including Rick\u2019s native UK and the US Billboard Hot 100. It also won the Brit Award for Best single in 1988. Stock Aitken and Waterman wrote and produced the track which was the lead-off single and lead track from Rick\u2019s debut LP \u201cWhenever You Need Somebody\u201d. The album was itself a UK number one and would go on to sell over 15 million copies worldwide.\n\nThe legendary video was directed by Simon West \u2013 who later went on to make Hollywood blockbusters such as Con Air, Lara Croft \u2013 Tomb Raider and The Expendables 2. The video passed the 1bn YouTube views milestone on 28 July 2021.\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/YTSubID\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/FBFollowID \nTwitter: https://RickAstley.lnk.to/TwitterID \nInstagram: https://RickAstley.lnk.to/InstagramID \nWebsite: https://RickAstley.lnk.to/storeID \nTikTok: https://RickAstley.lnk.to/TikTokID\n\nListen to Rick Astley:\nSpotify: https://RickAstley.lnk.to/SpotifyID \nApple Music: https://RickAstley.lnk.to/AppleMusicID \nAmazon Music: https://RickAstley.lnk.to/AmazonMusicID \nDeezer: https://RickAstley.lnk.to/DeezerID \n\nLyrics:\nWe\u2019re no strangers to love\nYou know the rules and so do I\nA full commitment\u2019s what I\u2019m thinking of\nYou wouldn\u2019t get this from any other guy\n\nI just wanna tell you how I\u2019m feeling\nGotta make you understand\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\nWe\u2019ve known each other for so long\nYour heart\u2019s been aching but you\u2019re too shy to say it\nInside we both know what\u2019s been going on\nWe know the game and we\u2019re gonna play it\n\nAnd if you ask me how I\u2019m feeling\nDon\u2019t tell me you\u2019re too blind to see\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\n#RickAstley #NeverGonnaGiveYouUp #WheneverYouNeedSomebody #OfficialMusicVideo", + "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", + "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw", + "duration": 212, + "view_count": 1447363306, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "categories": ["Music"], + "tags": [ + "rick astley", + "Never Gonna Give You Up", + "nggyu", + "never gonna give you up lyrics", + "rick rolled", + "Rick Roll", + "rick astley official", + "rickrolled", + "Fortnite song", + "Fortnite event", + "Fortnite dance", + "fortnite never gonna give you up", + "rick roll", + "rickrolling", + "rick rolling", + "never gonna give you up", + "80s music", + "rick astley new", + "animated video", + "rickroll", + "meme songs", + "never gonna give u up lyrics", + "Rick Astley 2022", + "never gonna let you down", + "animated", + "rick rolls 2022", + "never gonna give you up karaoke" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 2200000, + "chapters": null, + "heatmap": [], + "like_count": 16843103, + "channel": "Rick Astley", + "channel_follower_count": 3870000, + "channel_is_verified": true, + "uploader": "Rick Astley", + "uploader_id": "@RickAstleyYT", + "uploader_url": "https://www.youtube.com/@RickAstleyYT", + "upload_date": "20091025", + "availability": "public", + "original_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube", + "playlist": null, + "playlist_index": null, + "display_id": "dQw4w9WgXcQ", + "fulltitle": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "duration_string": "3:32", + "is_live": false, + "was_live": false, + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694783695, + "requested_formats": [ + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-aigzrnld/ms/au%2Conr/mv/m/mvi/3/pl/22/tx/24388769/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/2095000/vprv/1/go/1/mt/1694783390/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246%2C24362685/beids/24350017/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIfSO7YqwqqN5rvPVra8z_X7uhf3eONbgq6Wd7dUkRvQAIhAN_9UHWwCMbxaNIcBGwJcd2U7eGn8mcxLopjD2_obq4_/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIhAJsMzACAa3MAibS4ggmRWMpBoF5cEu2OPzA18PGS6JZiAiA0ooqsQhZJHwLRL5tKAUYrAUq_eY-wsx7nWV3SAiAbCw%3D%3D/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": 99, + "format_note": "Premium", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 5704.254, + "format": "616 - 1920x1080 (Premium)" + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783390&fvip=3&keepalive=yes&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAK3VvF4KH-4nYQUP1gpSURVLxA9j_1qSnMFHt4a8Stk8AiEA8wi7_ubVv4HzCGjW_pWZaUBRNXJaQ-1GuAAJovlF_E8%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 129.689, + "format": "251 - audio only (medium)" + } + ], + "format": "616 - 1920x1080 (Premium)+251 - audio only (medium)", + "format_id": "616+251", + "ext": "webm", + "protocol": "m3u8_native+https", + "language": "en", + "format_note": "Premium+medium", + "filesize_approx": 3437753, + "tbr": 5833.943, + "width": 1920, + "height": 1080, + "resolution": "1920x1080", + "fps": 25.0, + "dynamic_range": "SDR", + "vcodec": "vp09.00.40.08", + "vbr": 5704.254, + "stretched_ratio": null, + "aspect_ratio": 1.78, + "acodec": "opus", + "abr": 129.689, + "asr": 48000, + "audio_channels": 2 +} diff --git a/tests/components/media_extractor/fixtures/youtube_1_result_bestaudio.json b/tests/components/media_extractor/fixtures/youtube_1_result_bestaudio.json new file mode 100644 index 00000000000..308b43a39f9 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_1_result_bestaudio.json @@ -0,0 +1,2264 @@ +{ + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "formats": [ + { + "format_id": "sb2", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "width": 48, + "height": 27, + "fps": 0.4716981132075472, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "duration": 212.0 + } + ], + "resolution": "48x27", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb2 - 48x27 (storyboard)" + }, + { + "format_id": "sb1", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "width": 80, + "height": 45, + "fps": 0.5094339622641509, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 196.29629629629628 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 15.703703703703724 + } + ], + "resolution": "80x45", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb1 - 80x45 (storyboard)" + }, + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "width": 160, + "height": 90, + "fps": 0.5094339622641509, + "rows": 5, + "columns": 5, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M2.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M3.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M4.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 15.703703703703724 + } + ], + "resolution": "160x90", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb0 - 160x90 (storyboard)" + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D1294944%3Bdur%3D212.183%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1694042119353699/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIgXkd8x1p9qzd2j33lOyZV42nErv3V7DI_c6VxUO81MicCIQDYoX5ygRN4QPASau7kQ7iyPd1CcP4gFR4ocpVOu7Ql4w%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgNW-mInu4qMdbNLCOxR-yh-5-tb2-tl27vi7PeWp_TN0CIF-Q7eFVTvhGvlyPGQIC-2pFLPkr8AELeobAL63PQB4I/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "233 - audio only (Default)" + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D3433514%3Bdur%3D212.091%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1694042124987733/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIgZa77CyKyBzfX0ygVmHFcsZUv3yC-RJ0VQJzii3TWHxUCIQDo74AX1uTaERaFpGWfrIkbhxcvmV6SIAS_ix9VOQEGzw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAPHYcs-8I7Ze2T23fJk6MCHoiAG_5Tu2YX03KS42YFmEAiB7ILkCUEzIXLA9IdxSOWv9apPOQz_3pIfG7-F0QE2mBQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "234 - audio only (Default)" + }, + { + "asr": 22050, + "filesize": 817805, + "format_id": "599", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 30.833, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=599&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=817805&dur=212.183&lmt=1694040788792847&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAI_bE9DSOHyCMgNfFt9nrATs4DRujKS4YucEj5nO29irAiEA5e524FKJkawf7eCdZSzkVYNyejS2CfDMUCZKYMlO0x8%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 30.833, + "format": "599 - audio only (ultralow)" + }, + { + "asr": 48000, + "filesize": 832823, + "format_id": "600", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 31.418, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=600&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=832823&dur=212.061&lmt=1694040798740210&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgYs6oKSX5FlxqbWOfmux7P0JUBvOMbRhAa470efqLTkQCIC-tHzjH0-uR_Os8IQmsaQqF3L1jIqGUo3LwEdaYZCAR&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 31.418, + "format": "600 - audio only (ultralow)" + }, + { + "asr": 22050, + "filesize": 1294944, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 48.823, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=139&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=1294944&dur=212.183&lmt=1694042119353699&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgeL1-yrjqVMYmj-HE7FzWxzRtzXt9NLSDRf-wpE4R_1wCIQDMyLhuFZDSpGx5VPddUhnd4G3dlT5ptmwD0pZVP1gEVA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 48.823, + "format": "139 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 1232413, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 46.492, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=249&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1232413&dur=212.061&lmt=1694040798737498&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMm5hvk9HKKir9EzGVZNYX1OXgO80mxX9DsKa4UhzBWIAiB8xQEAMD6v9gm_IcXr6OKMMQyEr6SJR2zEpaF165ZyUA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 46.492, + "format": "249 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 1630086, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 61.494, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=250&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1630086&dur=212.061&lmt=1694040798724510&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPmH9rWSy2FgeIuFNGJwypYrWir9Swzj7paOT3H361mDAiBhx0X3OUVatDSWXUoxO6givpL61YWs44SqnQ9JEcRhbA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 61.494, + "format": "250 - audio only (low)" + }, + { + "asr": 44100, + "filesize": 3433514, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.51, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=140&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=3433514&dur=212.091&lmt=1694042124987733&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAN2RErItVbu3k8nIfPO8NcCKxwLL0uX_GFXP0VpbMR5vAiBEWlvWa-nDlR6wmTefzQlGaM5FaDqHQh9Pm5aQ-etaqg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 129.51, + "format": "140 - audio only (medium)" + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAL5gfx40AmiGAp0vSd50hypQlBE4W-Qo5iiD95oYKtH-AiEAybTs2BuunUUrhfMdyEcuPxwPC8ww_-p-danCp9uAArc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 129.689, + "format": "251 - audio only (medium)" + }, + { + "asr": 22050, + "filesize": 2086732, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 6, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 78.693, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=17&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=2086732&dur=212.137&lmt=1694042486266781&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgXc6jqpr0Okm6Xrpv9kwH0gYRdS7d8reJudfbSscQG64CIQDvpzrhNmE47BajCckrEUi2oezc7t9QBW1ntvC8JRYgtA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 176, + "language": "en", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "176x144", + "aspect_ratio": 1.22, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "3gp", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "17 - 176x144 (144p)" + }, + { + "asr": null, + "filesize": 847252, + "format_id": "597", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 31.959, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=597&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=847252&dur=212.080&lmt=1694042194934376&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgfjO4rNa_oBrurVMhq66M99RPPwm45aYzSWXz4V53Ot0CIENxgQXCDHqdjTXtO9NT_gU1sbTYA5rDJ6SQjAowt0De&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4d400b", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 31.959, + "format": "597 - 256x144 (144p)" + }, + { + "format_id": "602", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/602/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D643306%3Bdur%3D212.080%3Bgir%3Dyes%3Bitag%3D598%3Blmt%3D1694042224218554/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgcAnEndUr_xkLfgS0SnKnb4heEtzfOurEKvglIUDG64ECIQC7iIqUdvT_ooEV-rLA2Q2BOyaUCkEvvP6eGT0Hqnu73w%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgVqi7Sv_zMX3Fd182Lkxf8pBBsZl5S5qignj5KA7Ung0CIQCWclCKYj00R_QS-mciAKxsTh2CjaXueF4Q7MjdlQeMaQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 80.559, + "ext": "mp4", + "fps": 13.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.10.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 80.559, + "format": "602 - 256x144" + }, + { + "asr": null, + "filesize": 643306, + "format_id": "598", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 24.266, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=598&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=643306&dur=212.080&lmt=1694042224218554&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAK9n1La19b0lg9KpcV6H-jtXpX52S07qiX2TkafUCKHCAiBcBBRjAxYASJWWpEe8GIEVfeUrhvP4DMVz-JpY1NES1Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp9", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 24.266, + "format": "598 - 256x144 (144p)" + }, + { + "asr": null, + "filesize": 1416915, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 53.458, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=394&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1416915&dur=212.040&lmt=1694042192787352&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALFZcl6qpHntlTCo_m-ouchCCib6GX7tngoF4X2Iyfd3AiBSd9xw6SXXlQyUrHEJbxkTrfpF9ubFg6KJAuFwP9f3xw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 53.458, + "format": "394 - 256x144 (144p)" + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D1863601%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1694045032286738/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgQCS6upmhOwB8qDvvR7rW5cE3xyeXjpHQIiAX6lx9oKYCIDyXRvU1Iu5LIWFCQ6QFmJ78UIyVuV_YUJF-jfok5qir/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAI_RZhf1Ru3O5EKLZ2Z-qjhMffd1sRFHgulaQHiNefu0AiB4xHj4OmYIMrue0LZKOa_rJm3e9bmYMNwGAi8dfw06pQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 156.229, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 156.229, + "format": "269 - 256x144" + }, + { + "asr": null, + "filesize": 1863601, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 70.311, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=160&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1863601&dur=212.040&lmt=1694045032286738&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgNaaek385L4ctiA9PSdp7ZmdpudYVaOrZnDit2OykRPkCIQC8O31-81F2J2tZytFdstt3BqRAAtNXPAvXOkLYeYBeYw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 70.311, + "format": "160 - 256x144 (144p)" + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D2404581%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1694043382822868/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALQTWi8p3XMu1h-eOUdXyc00hPdYv78OHDEQi8uxYnXMAiBcoHLH7IEnjRymhqAat1tG5-YnxV-9ye3V7KDJtUJMPQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgOxESKety9F2fZ5r8O43_gUR9xJ_yjzbQ2CBQIlgps0oCIQC4ArPaIpjt6J7qTJOmUggLPuiyPHRv474nKCjT1AiOFA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 153.593, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 153.593, + "format": "603 - 256x144" + }, + { + "asr": null, + "filesize": 2404581, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 90.721, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=278&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=2404581&dur=212.040&lmt=1694043382822868&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAIT3oQ5Em_Qkt76EC-ig_mB-5D7ubDgUX8CEtnsWqmsAAiAIFzLFgVdGphU__C9zMbNXxmxC2cubwG7AbWB_WsojiA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "256x144", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 90.721, + "format": "278 - 256x144 (144p)" + }, + { + "asr": null, + "filesize": 3024455, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 114.108, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=395&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3024455&dur=212.040&lmt=1694042297309821&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOBrAPa-X6d_nVKeVXl8ddkfVG-uUAK6NSlVr3HpMwXbAiB8yr5NQfM90ZcU_oxBeIiMFVOJmx3NCmv09WvaycJQ5Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 114.108, + "format": "395 - 426x240 (240p)" + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D3019976%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1694045014258984/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAK2yZB-LpVu7_6FFB80HWx7hWoWkVw0S_w-QqUbIE8OJAiEA-3uSVPXggsuMHJp4vECyXEwukQoswRROk30WkuEpdu0%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAOxiPl9Yxyvs_0LF5tCaV-Z6e5_tamHAcKQxBh7St8tkAiEAulmwoGSX4EBiFmSTwJ99n49wRWxcpQh1hZK1xIdUVPc%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 225.675, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 225.675, + "format": "229 - 426x240" + }, + { + "asr": null, + "filesize": 3019976, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 113.939, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=133&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3019976&dur=212.040&lmt=1694045014258984&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAP7-DRzbJykHUKJeRsn6HtUaL0eUzqWESp-ympAfU5nTAiB1ez2yiKGOyhd-RqshrcyB18qQe2WSrCdUhy5v9pqP3A%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 113.939, + "format": "133 - 426x240 (240p)" + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D4021173%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1694043379783601/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAJXExrNJa22VZvMfaTzD429YPHRcemBT9-3QvF08XM1hAiEAn7meUognCFsmZthQEPIOD6k3Bvc9ZABis61S21hDBRQ%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgbhhyD5yqyyIYiONN9J1mtDoXKD0w6rOgyDu6M06jnDECIQC-7cqHY9oqU0pwUlDmdHNwa_WKtxsZcchI7r3TmHR3dQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 287.523, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 287.523, + "format": "604 - 426x240" + }, + { + "asr": null, + "filesize": 4021173, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 151.713, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=242&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=4021173&dur=212.040&lmt=1694043379783601&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhANcW3B_2RE3Jt_ysw-TH_vr_cJcDq8IlGwzMfl8KJbuLAiEAxQv9P2cZvIUvOaq74GZfBgG6iJv39AfrfINDoXbVtwQ%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "426x240", + "aspect_ratio": 1.77, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 151.713, + "format": "242 - 426x240 (240p)" + }, + { + "asr": null, + "filesize": 5438397, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 205.183, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=396&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5438397&dur=212.040&lmt=1694042190822892&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgLzCC-yPo2ZSTmKsJ2e4jRyjlCZwMjAv8ZZkFusa1TTACIQCAqe_xOIth5xTCP_pgta9y03V39gvaGzHtL3YVa5D_aA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 205.183, + "format": "396 - 640x360 (360p)" + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D5678772%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1694045013473544/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAPsd8-fEmDJQlMCGNL9vmN4MUF_zlN5PLfr0DYfVoezzAiBU7T2m437twygSiryf0b-u-OIsQTeldPovQ-zSp0ETkw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhALsd4jV3YmEG-liDMzghp0L-sTyGWpDf1iMm_UzTHXs9AiAIAfs4pb5FPTUO0hJxAocJtAUunw2KTmMXci4VOGTaGA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 478.155, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 478.155, + "format": "230 - 640x360" + }, + { + "asr": null, + "filesize": 5678772, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 214.252, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=134&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5678772&dur=212.040&lmt=1694045013473544&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgI9UX8Vzs8WLiUAfYg7PAfKfqjj8B9Wja5aUfFUGO59gCIQCGtnmhUpjWdxW1suD-k_EmGlMnwCZ3xXTSD8MQSLsXFg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 214.252, + "format": "134 - 640x360 (360p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 343.32, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=18&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045104514388&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4538434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgQ9O6CEkJyhZCM4l7TXmLw-1IyhS5pynH9hVHJ1yzDeQCIAZmevy2E9fWT3oyNdbJyqHI-H7nfWWWfWaaMm-oRnX4&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 640, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "filesize_approx": 9316331, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "18 - 640x360 (360p)" + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D6902164%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1694043349554753/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAJxexWH26Vo4tGjaU7SVipOCPxzVsmcFOarU_bRj0ay-AiB6QmeXiAc_08341YesRBEC6KgIU3HT-cwPdhnCgAtOVw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIVHx3z1WRFDckwGBmogzli7IcwwILf__AQQpA4kOSckAiEA550IxTq_8KIYplwlBr7OLXHegKhoOl5rsgl-T8znV24%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 566.25, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 566.25, + "format": "605 - 640x360" + }, + { + "asr": null, + "filesize": 6902164, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 260.409, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=243&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=6902164&dur=212.040&lmt=1694043349554753&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFlaKhvga5JkqykyXnX2xE8XiiqYW_VvEBrecRfwfwtwCIDJCxTDJPzsPOAHdblWxH8cTlefGYqGq3n6gWISz4Mg6&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "640x360", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 260.409, + "format": "243 - 640x360 (360p)" + }, + { + "asr": null, + "filesize": 9434981, + "format_id": "397", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 355.969, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=397&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=9434981&dur=212.040&lmt=1694042458043976&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgGIoLoe44Xe1thEQ_qjsc2WHA1GPW-htWsPL_QfPjgSQCIHq1BE6WV_vRQ1OkcSmYRHHpfP3c5daevv5WwHUD3wqW&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.04M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 355.969, + "format": "397 - 854x480 (480p)" + }, + { + "format_id": "231", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/231/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D8683274%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D135%3Blmt%3D1694045045723793/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhALPVRc2f2Obkuz1sdr6BV9MyEnkUBzkmMGM5Hqci5JuJAiEA8F1Yul9YL-Zry_0wpNgq3U1y2ZNVbIPicWR73h_JuKo%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgMSeNvXWq1qWsFqCjU9l8OfbjtWzVvVvyM_AIfmvT-nYCIEvrRlrArUOUeo9HWQ7Q75Va9V2bQfJosWVypJauqciw/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 660.067, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 660.067, + "format": "231 - 854x480" + }, + { + "asr": null, + "filesize": 8683274, + "format_id": "135", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 327.608, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=135&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=8683274&dur=212.040&lmt=1694045045723793&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAN7VpnHyU08_5bd3nLSN0T_I0G0XKAGpstkSN8hJmG7SAiEAoywzkEOX7Xvl3gJxH22DIWZlTFoUACTgUR3KCdWnNII%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 327.608, + "format": "135 - 854x480 (480p)" + }, + { + "format_id": "606", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/606/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D10927666%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D244%3Blmt%3D1694043369037289/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgKYQax37qT30x1n2xlCZ-MNpGPVjPCOLXz86zUuioIg0CIQDulgGfFF1mDON6pqV0wlYYEvqe5XvcPBxS-Fxu994RqQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIa8QTnhFp9MOZ1suF489yzC2ZkBGVPLp2bEzz9dR8WIAiEAnoLnAxgbtt5aNYJUgBTY9ms7VVWkHHKCiH638UCuKtk%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 733.359, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 733.359, + "format": "606 - 854x480" + }, + { + "asr": null, + "filesize": 10927666, + "format_id": "244", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 412.286, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=244&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=10927666&dur=212.040&lmt=1694043369037289&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIfXrfMaA8LBSnaF3occA67EUNFJ1_7SIva5fm0zJlQfAIhAIipqpihUgBYI2wfaLJFfgptt_mW9nzrCV9gmy4NwfOs&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "854x480", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 412.286, + "format": "244 - 854x480 (480p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "22", + "format_note": "720p", + "source_preference": -5, + "fps": 25, + "audio_channels": 2, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 762.182, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAO2IJciEtkI3PvYyVC_zkyo61I70wYJQXuGOMueeacrKAiA-UAdaJSlqqkfaa6QtqVnC_BJJZn7BXs85gh_fdbGoSg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 1280, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.64001F", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "filesize_approx": 20682570, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "22 - 1280x720 (720p)" + }, + { + "asr": null, + "filesize": 17466721, + "format_id": "398", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 658.997, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=398&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=17466721&dur=212.040&lmt=1694042319819525&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAPywBnw1_I4KYIHKK24AJyYZfWhx7SQJVRZ8uBGqXJhKAiEAxq_PQFEOlJYSrgyXUUjebDhJqF95a2RjUpPY2qES9PY%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.05M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 658.997, + "format": "398 - 1280x720 (720p)" + }, + { + "format_id": "232", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/232/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D16780212%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D136%3Blmt%3D1694045071129751/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgbhrGclyS72fpLtzdXVU9RDmFGHjBDZouGsDQpvbUQC4CIQDpfA4mDk5nk89ajQcD_glSxmcquRJP60hXgPxY_I_ZBw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAMuqkbeFuDFY6HmhH7v3KH-qIYGXKazEx-mLveUdmpdpAiEAs2EroRUbYwlaalalseSytVEqn6JsUcZiitcLMEMfGbM%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 1130.986, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1130.986, + "format": "232 - 1280x720" + }, + { + "asr": null, + "filesize": 16780212, + "format_id": "136", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 633.096, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=136&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=16780212&dur=212.040&lmt=1694045071129751&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgSRRddsHyGpCxzgpSqQxmStMIq_Gm7czCOT98gtLFwwkCIGVj5J0frFAsFbd4YVMZWQlnTH1K32SUuJ8OhY8Ka7FT&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 633.096, + "format": "136 - 1280x720 (720p)" + }, + { + "format_id": "609", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/609/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D15359727%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D247%3Blmt%3D1694043486219683/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAOL1Az7tRckedkj_PlDPG_MLb7ZLWQR3lkfCPgIJXd2ZAiEArFERGBCxvgl1prUWkikX0zu8y25kKGIddtOLea5sxVA%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhALJWjDpuy9OEznLN5GSPvuFnF9fybPAeESDF9b8fwGUPAiBSBQF0jpakjC9BBw-hLVF2AunwNfzMUaxajirGWWDCpA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 1179.472, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1179.472, + "format": "609 - 1280x720" + }, + { + "asr": null, + "filesize": 15359727, + "format_id": "247", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 579.502, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=247&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=15359727&dur=212.040&lmt=1694043486219683&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgOZCiWUfgJJ7EhGWaF7VH8ClUVLTgxTj4BwkKSlBgYiYCIQCfzpz7XVToSR5C20eqqfJN2-Arc5LpaYp8QySapFOS5A%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1280x720", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 579.502, + "format": "247 - 1280x720 (720p)" + }, + { + "asr": null, + "filesize": 31265835, + "format_id": "399", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1179.62, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=399&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=31265835&dur=212.040&lmt=1694042163788395&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAP3UyiR-zsKUxBoO4PBga2JD3Yd3hKqOqvH7ImC75ulSAiA3-7uZ0rcIPZI-ozv9d1IGSMaMN6_cLePQLeo78PFcsw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.08M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 1179.62, + "format": "399 - 1920x1080 (1080p)" + }, + { + "format_id": "270", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/270/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D80166145%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1694045208995966/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgD36sIi3KkXGfoFndEWd4zwW1I_CM_QTl8bgFexU9pOYCIEwpCkWFOrqNTbezacGSmfL5A7K1nsntn_bunWWYwpng/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgM_-3XdDrdsnF4FioWwe9vRaX2iRWlRDklNoUaIGHuaQCIQDNgXEH4j6FtvPy3ccR1TWikUHWev1h2ysvaEiOtiNM4A%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 4901.412, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 4901.412, + "format": "270 - 1920x1080" + }, + { + "asr": null, + "filesize": 80166145, + "format_id": "137", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 3024.566, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=137&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=80166145&dur=212.040&lmt=1694045208995966&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgPfkitkkFuxF18kiTpRsPUzhfBZsp5WX8WR16WT4FWN4CIFsiyOwbYDHzytWsvXweLw50nXGVRFDcIjy-lCSY9kmK&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 3024.566, + "format": "137 - 1920x1080 (1080p)" + }, + { + "format_id": "614", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/614/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D40874930%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D248%3Blmt%3D1694044655610179/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgbn5GlPWDCdfs18jV5BDKc6IVmgq5xKuZIs53-6LbJbACIQDIBnFJvI5uQmRy7_LHF_bUjAX46uT0y2xMxJQxUaABaw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhALtFWwbQc0bud7Owmpa-scrnBCDk6O14mBrEOGNfsGVBAiBYOeKoRWnfgZKPfO7zNJLD9T8Ed4dTa9BRZeDs51IT9g%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 2831.123, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 2831.123, + "format": "614 - 1920x1080" + }, + { + "asr": null, + "filesize": 40874930, + "format_id": "248", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1542.159, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgT7VwysCFd3nXvaSSiJoVxkNj5jfMPSeitLsQmy_S1b4CIQDWFiZSIH3tV4hQRtHa9DbzdYL8RQpbKD_6aeNZ7t-3IA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 1542.159, + "format": "248 - 1920x1080 (1080p)" + }, + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": 99, + "format_note": "Premium", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 5704.254, + "format": "616 - 1920x1080 (Premium)" + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/default.webp", + "height": 90, + "width": 120, + "preference": -12, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11, + "id": "26", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10, + "id": "27", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/0.jpg", + "preference": -9, + "id": "28" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/0.webp", + "preference": -8, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDd2KtelLHaNSXrI9_5K-NvTscKNw", + "height": 94, + "width": 168, + "preference": -7, + "id": "30", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBUpEOOWUXWkNyijQuZ4UPzp2BE-w", + "height": 110, + "width": 196, + "preference": -7, + "id": "31", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBCyhr8AqpJ1SxKVU6SyK5ODJ_IpA", + "height": 138, + "width": 246, + "preference": -7, + "id": "32", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_p0PncTtkrhaNDZtntrE3gKkoYw", + "height": 188, + "width": 336, + "preference": -7, + "id": "33", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7, + "id": "34", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6, + "id": "35", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5, + "id": "36", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4, + "id": "37", + "resolution": "640x480" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg", + "preference": -3, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq720.webp", + "preference": -2, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1, + "id": "40", + "resolution": "1280x720" + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0, + "id": "41", + "resolution": "1920x1080" + } + ], + "thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "description": "The official video for \u201cNever Gonna Give You Up\u201d by Rick Astley\n\n\u2018Hold Me In Your Arms\u2019 \u2013 deluxe blue vinyl, 2CD and digital deluxe out 12th May 2023 Pre-order here \u2013 https://rick-astley.lnk.to/HMIYA2023ID\n\n\u201cNever Gonna Give You Up\u201d was a global smash on its release in July 1987, topping the charts in 25 countries including Rick\u2019s native UK and the US Billboard Hot 100. It also won the Brit Award for Best single in 1988. Stock Aitken and Waterman wrote and produced the track which was the lead-off single and lead track from Rick\u2019s debut LP \u201cWhenever You Need Somebody\u201d. The album was itself a UK number one and would go on to sell over 15 million copies worldwide.\n\nThe legendary video was directed by Simon West \u2013 who later went on to make Hollywood blockbusters such as Con Air, Lara Croft \u2013 Tomb Raider and The Expendables 2. The video passed the 1bn YouTube views milestone on 28 July 2021.\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/YTSubID\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/FBFollowID \nTwitter: https://RickAstley.lnk.to/TwitterID \nInstagram: https://RickAstley.lnk.to/InstagramID \nWebsite: https://RickAstley.lnk.to/storeID \nTikTok: https://RickAstley.lnk.to/TikTokID\n\nListen to Rick Astley:\nSpotify: https://RickAstley.lnk.to/SpotifyID \nApple Music: https://RickAstley.lnk.to/AppleMusicID \nAmazon Music: https://RickAstley.lnk.to/AmazonMusicID \nDeezer: https://RickAstley.lnk.to/DeezerID \n\nLyrics:\nWe\u2019re no strangers to love\nYou know the rules and so do I\nA full commitment\u2019s what I\u2019m thinking of\nYou wouldn\u2019t get this from any other guy\n\nI just wanna tell you how I\u2019m feeling\nGotta make you understand\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\nWe\u2019ve known each other for so long\nYour heart\u2019s been aching but you\u2019re too shy to say it\nInside we both know what\u2019s been going on\nWe know the game and we\u2019re gonna play it\n\nAnd if you ask me how I\u2019m feeling\nDon\u2019t tell me you\u2019re too blind to see\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\n#RickAstley #NeverGonnaGiveYouUp #WheneverYouNeedSomebody #OfficialMusicVideo", + "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", + "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw", + "duration": 212, + "view_count": 1447363306, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "categories": ["Music"], + "tags": [ + "rick astley", + "Never Gonna Give You Up", + "nggyu", + "never gonna give you up lyrics", + "rick rolled", + "Rick Roll", + "rick astley official", + "rickrolled", + "Fortnite song", + "Fortnite event", + "Fortnite dance", + "fortnite never gonna give you up", + "rick roll", + "rickrolling", + "rick rolling", + "never gonna give you up", + "80s music", + "rick astley new", + "animated video", + "rickroll", + "meme songs", + "never gonna give u up lyrics", + "Rick Astley 2022", + "never gonna let you down", + "animated", + "rick rolls 2022", + "never gonna give you up karaoke" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 2200000, + "chapters": null, + "heatmap": [], + "like_count": 16843101, + "channel": "Rick Astley", + "channel_follower_count": 3870000, + "channel_is_verified": true, + "uploader": "Rick Astley", + "uploader_id": "@RickAstleyYT", + "uploader_url": "https://www.youtube.com/@RickAstleyYT", + "upload_date": "20091025", + "availability": "public", + "original_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube", + "playlist": null, + "playlist_index": null, + "display_id": "dQw4w9WgXcQ", + "fulltitle": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "duration_string": "3:32", + "is_live": false, + "was_live": false, + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694783669, + "requested_formats": [ + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C29/mn/sn-5hne6nzy%2Csn-5hnekn7k/ms/au%2Crdu/mv/m/mvi/3/pl/22/tx/24388770/txs/24388767%2C24388768%2C24388769%2C24388770%2C24388771%2C24388772%2C24388773/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1957500/vprv/1/go/1/mt/1694783146/fvip/2/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Ctx%2Ctxs%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRQIhAL8hNlWRffc33Ibzb-OCH34lon8WNpNiKHeUFUFMzvlDAiAr5e33CzsKEX8k0MiF68H_7xmDW2b6HQSW0uiiFr2Rxg%3D%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRAIgF4H6KcG8d2n_e4oe9m5iMfJFj7-zvjFtzJfWCByyfVwCIDLMnTkaV3Szw249SnaNqBAw5vMO_DCwPJZkqzdT0P5p/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": 99, + "format_note": "Premium", + "resolution": "1920x1080", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 5704.254, + "format": "616 - 1920x1080 (Premium)" + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAL5gfx40AmiGAp0vSd50hypQlBE4W-Qo5iiD95oYKtH-AiEAybTs2BuunUUrhfMdyEcuPxwPC8ww_-p-danCp9uAArc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 129.689, + "format": "251 - audio only (medium)" + } + ], + "format": "616 - 1920x1080 (Premium)+251 - audio only (medium)", + "format_id": "616+251", + "ext": "webm", + "protocol": "m3u8_native+https", + "language": "en", + "format_note": "Premium+medium", + "filesize_approx": 3437753, + "tbr": 5833.943, + "width": 1920, + "height": 1080, + "resolution": "1920x1080", + "fps": 25.0, + "dynamic_range": "SDR", + "vcodec": "vp09.00.40.08", + "vbr": 5704.254, + "stretched_ratio": null, + "aspect_ratio": 1.78, + "acodec": "opus", + "abr": 129.689, + "asr": 48000, + "audio_channels": 2 +} diff --git a/tests/components/media_extractor/fixtures/youtube_empty_playlist_info.json b/tests/components/media_extractor/fixtures/youtube_empty_playlist_info.json new file mode 100644 index 00000000000..ceec0d28db2 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_empty_playlist_info.json @@ -0,0 +1,49 @@ +{ + "id": "PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "title": "Very important videos", + "availability": "public", + "channel_follower_count": null, + "description": "Not original", + "tags": [], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCKgBEF5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLDBCH5IQ0obogxXhAzIH8pE0d7r1Q", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCMQBEG5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLAybhgn-CoPMjBE-0VfBDqvy0jyOQ", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCPYBEIoBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLDutIdjr5zTE9G78eWf83-mGXYnUA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCNACELwBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLD2884fHuvAv8ysHA48LD3uArB6bA", + "height": 188, + "width": 336 + } + ], + "modified_date": "20230813", + "view_count": 5680730, + "playlist_count": 5, + "channel": "Armand314", + "channel_id": "UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_id": "@Armand314", + "uploader": "Armand314", + "channel_url": "https://www.youtube.com/channel/UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_url": "https://www.youtube.com/@Armand314", + "_type": "playlist", + "entries": [], + "extractor_key": "YoutubeTab", + "extractor": "youtube:tab", + "webpage_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "original_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "webpage_url_basename": "playlist", + "webpage_url_domain": "youtube.com", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/youtube_playlist_info.json b/tests/components/media_extractor/fixtures/youtube_playlist_info.json new file mode 100644 index 00000000000..c1d39365387 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_playlist_info.json @@ -0,0 +1,265 @@ +{ + "id": "PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "title": "Very important videos", + "availability": "public", + "channel_follower_count": null, + "description": "Not original", + "tags": [], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCKgBEF5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLDBCH5IQ0obogxXhAzIH8pE0d7r1Q", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCMQBEG5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLAybhgn-CoPMjBE-0VfBDqvy0jyOQ", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCPYBEIoBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLDutIdjr5zTE9G78eWf83-mGXYnUA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCNACELwBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLD2884fHuvAv8ysHA48LD3uArB6bA", + "height": 188, + "width": 336 + } + ], + "modified_date": "20230813", + "view_count": 5680730, + "playlist_count": 5, + "channel": "Armand314", + "channel_id": "UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_id": "@Armand314", + "uploader": "Armand314", + "channel_url": "https://www.youtube.com/channel/UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_url": "https://www.youtube.com/@Armand314", + "_type": "playlist", + "entries": [ + { + "_type": "url", + "ie_key": "Youtube", + "id": "q6EoRBvdVPQ", + "url": "https://www.youtube.com/watch?v=q6EoRBvdVPQ", + "title": "Yee", + "description": null, + "duration": 10, + "channel_id": "UC-fD_qwTEQQ1L-MUWx_mNvg", + "channel": "revergo", + "channel_url": "https://www.youtube.com/channel/UC-fD_qwTEQQ1L-MUWx_mNvg", + "uploader": "revergo", + "uploader_id": "@revergo", + "uploader_url": "https://www.youtube.com/@revergo", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAJYg16HMBdEsv9lYBJyNqA5G3anQ", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAgCNP9UuQas-D59hHHM-RqkUvA6g", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCTWaY5897XxhcpRyVtGQQNuMHfTg", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCeS6NC75yTYvyP4DsehZ3oXNuxMQ", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 96000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "8YWl7tDGUPA", + "url": "https://www.youtube.com/watch?v=8YWl7tDGUPA", + "title": "color red", + "description": null, + "duration": 17, + "channel_id": "UCbYMTn6xKV0IKshL4pRCV3g", + "channel": "Alex Jimenez", + "channel_url": "https://www.youtube.com/channel/UCbYMTn6xKV0IKshL4pRCV3g", + "uploader": "Alex Jimenez", + "uploader_id": "@alexjimenez1237", + "uploader_url": "https://www.youtube.com/@alexjimenez1237", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AG2BIACwAKKAgwIABABGGUgXShUMA8=&rs=AOn4CLBqzngIx-4i_HFvqloetUfeN8yrYw", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AG2BIACwAKKAgwIABABGGUgXShUMA8=&rs=AOn4CLB7mWPQmdL2QBLxTHhrgbFj2jFaCg", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBtgSAAsACigIMCAAQARhlIF0oVDAP&rs=AOn4CLA9YAIO3g_DnClsuc5LjMQn4O9ZQQ", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBtgSAAsACigIMCAAQARhlIF0oVDAP&rs=AOn4CLDPHY6aG08hlTJMlc-LJt9ywtpWEg", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 30000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "6bnanI9jXps", + "url": "https://www.youtube.com/watch?v=6bnanI9jXps", + "title": "Terrible Mall Commercial", + "description": null, + "duration": 31, + "channel_id": "UCLmnB20wsih9F5N0o5K0tig", + "channel": "quantim", + "channel_url": "https://www.youtube.com/channel/UCLmnB20wsih9F5N0o5K0tig", + "uploader": "quantim", + "uploader_id": "@Potatoflesh", + "uploader_url": "https://www.youtube.com/@Potatoflesh", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAsyI0ZJA9STG8vlSdRkKk55ls5Dg", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD2bZ9S8AB4UGsZlx_8TjBoL72enA", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCKNlgvl_7lKoFq8vyDYZRtTs4woA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBeZv8F8IyICmKD9qjo9pTMJmM8ug", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 26000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "SBeYzoQPbu8", + "url": "https://www.youtube.com/watch?v=SBeYzoQPbu8", + "title": "name a yellow fruit", + "description": null, + "duration": 4, + "channel_id": "UCkRDJpXb96HrsdDSF8AwysA", + "channel": "DaRkMaGiCiAn5009", + "channel_url": "https://www.youtube.com/channel/UCkRDJpXb96HrsdDSF8AwysA", + "uploader": "DaRkMaGiCiAn5009", + "uploader_id": "@DaRkMaGiCiAn5009", + "uploader_url": "https://www.youtube.com/@DaRkMaGiCiAn5009", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/SBeYzoQPbu8/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHmAoAC6AKKAgwIABABGGUgUShHMA8=&rs=AOn4CLAZhgooxUn_fTi4K4OnWOcObof3TA", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/SBeYzoQPbu8/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHmAoAC6AKKAgwIABABGGUgUShHMA8=&rs=AOn4CLApcEdGLsf088qGyT2ITBRMD-toAg", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/SBeYzoQPbu8/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB5gKAAugCigIMCAAQARhlIFEoRzAP&rs=AOn4CLBv0kiYaUPOX8JIg1rASAUhtxBxxA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/SBeYzoQPbu8/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB5gKAAugCigIMCAAQARhlIFEoRzAP&rs=AOn4CLBXN-tNsOG3AzyPZ7UgOuwS7mEs7g", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 14000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "ixQkcuZhXg8", + "url": "https://www.youtube.com/watch?v=ixQkcuZhXg8", + "title": "The moment an old lady questions her own sanity", + "description": null, + "duration": 31, + "channel_id": "UCVsKh1uG6_T0o8nYrtmqmyA", + "channel": "Marcus", + "channel_url": "https://www.youtube.com/channel/UCVsKh1uG6_T0o8nYrtmqmyA", + "uploader": "Marcus", + "uploader_id": "@Marcuskb92", + "uploader_url": "https://www.youtube.com/@Marcuskb92", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/ixQkcuZhXg8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCelxa91oGbhu8LhhLkcSiHF7YSGg", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/ixQkcuZhXg8/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAAJnPf2Rl67uaRBR2lgkkBojkTiw", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/ixQkcuZhXg8/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBNoK36znIiRCoNE5tKnfF1oYXJ8A", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/ixQkcuZhXg8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCA_twfGS2acx005yqJgAYOT40qvQ", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 17000000, + "live_status": null, + "channel_is_verified": null + } + ], + "extractor_key": "YoutubeTab", + "extractor": "youtube:tab", + "webpage_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "original_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "webpage_url_basename": "playlist", + "webpage_url_domain": "youtube.com", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/youtube_playlist_result.json b/tests/components/media_extractor/fixtures/youtube_playlist_result.json new file mode 100644 index 00000000000..ae5072ecbb4 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_playlist_result.json @@ -0,0 +1,1351 @@ +{ + "id": "q6EoRBvdVPQ", + "title": "Yee", + "formats": [ + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/q6EoRBvdVPQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgi3_YjvBQ==&sigh=rs$AOn4CLDitSYn-lYL95DTEPMg_2O_KiuBDg", + "width": 48, + "height": 27, + "fps": 11.11111111111111, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/q6EoRBvdVPQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgi3_YjvBQ==&sigh=rs$AOn4CLDitSYn-lYL95DTEPMg_2O_KiuBDg", + "duration": 9.0 + } + ], + "resolution": "48x27", + "aspect_ratio": 1.78, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "none", + "video_ext": "none", + "vbr": 0, + "abr": 0, + "tbr": null, + "format": "sb0 - 48x27 (storyboard)" + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D56681%3Bdur%3D9.148%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1660945484047785/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRgIhAIIUXW7l2Q1N9xS5I-AR2telnZyDaJQaftrqcKgaFLzRAiEA0_31fEW0eHdGDCKTUvaPCaLOVcSzhbWC1GvuwMa0jeU%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAK2cOCSMsdA4Mw-otp1P8usOJv1VZQfypOVUfnc-U4JVAiEAza1YIXOviatFdW9jc95da8a-0TbKz7on-RMKz8jJS20%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "language": "es", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "233 - audio only (Default)" + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D147770%3Bdur%3D9.055%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1660945484356876/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRQIhAMABt0U4sY_mybpIH7LkXKqubiCQ7uj0AdysCPT7H6DHAiBzn6BY18K0lWocHbDAccjGPFdXr6ZzCnUmbHwO80WxuQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALtyE63XOF1RNgq6rBt7xE1ZL6mVPNw3V-o5pHdDiX95AiEAk0hvBk4F_5lek6pCxPvQ-EyvTsQiJJA_I6pVWXFTxhQ%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "language": "es", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1, + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "mp4", + "video_ext": "none", + "vbr": 0, + "abr": null, + "tbr": null, + "format": "234 - audio only (Default)" + }, + { + "asr": 22050, + "filesize": 56681, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 49.562, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=139&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=56681&dur=9.148&lmt=1660945484047785&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgMTEq7iYqWrL3WZ7ptBG-QvTumAooMR7hknyteXrtpAYCIQDVM_SjwgF8qOQJHHVx35PsreSlxcfmNIBXQkn5DcEb_A%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 49.562, + "format": "139 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 51092, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 45.309, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=249&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=51092&dur=9.021&lmt=1660945486064674&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAOj8MU8sihogln9ayHPLk2AyHHAFTzzv4C7gmAKZ5BJNAiA3o-GaC68jHxU3BlAcffo43FuRhkiR7BGrTZyJEZJ_uQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 45.309, + "format": "249 - audio only (low)" + }, + { + "asr": 48000, + "filesize": 64622, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 57.308, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=250&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=64622&dur=9.021&lmt=1660945475759533&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgCXSqHdL6u2fA4tpsNsevT3YQDR9HlMlFeWYQYBnbbp0CIBfbNs3IVtOe7kB2JpPFVo_XxwKwbu67JjkutEadCgWD&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 57.308, + "format": "250 - audio only (low)" + }, + { + "asr": 44100, + "filesize": 147770, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 130.538, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=140&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=147770&dur=9.055&lmt=1660945484356876&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgATfgfWIf5K3rL4Q_uA_9Cqx1Xq4viIABoTGmIZZ6dHMCID_TsF_fiNjYH6yVLdOmu7U5uSyxCQC2NvNymtg5aseO&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "m4a", + "video_ext": "none", + "vbr": 0, + "abr": 130.538, + "format": "140 - audio only (medium)" + }, + { + "asr": 48000, + "filesize": 123202, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 109.257, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=251&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=123202&dur=9.021&lmt=1660945472183333&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMvJsObaszrBWjSOzW_wD0jXOBJTmZsU0WpEG2pSqXaHAiBxmFJUftWY3sPtSdbaSoTYHfdHxOYHupAA85TROnKjIg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 109.257, + "format": "251 - audio only (medium)" + }, + { + "asr": 22050, + "filesize": 87229, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 7, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 76.667, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=17&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=87229&dur=9.102&lmt=1660945743115413&mt=1694796392&fvip=5&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAJm0eNev3CzRpx8At9YD6D6U_uxOlEflLMDjzqieM592AiBOg8lE3Gll9YjGcna1uGS30ErrF0kiBbANCYv3pAFxVg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 176, + "language": "es", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "176x144", + "aspect_ratio": 1.22, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "3gp", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "17 - 176x144 (144p)" + }, + { + "asr": null, + "filesize": 46882, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 41.635, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=394&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=46882&dur=9.008&lmt=1660945830044574&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPy1RiLiIziQCwBCdl9LzQs-qgBhFPr8kkunFNT_AAv_AiBO7wJUpUtjJNqmJPPdDvqzvyFyOLCZ8fypGl2mH5hHTQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 192, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 41.635, + "format": "394 - 192x144 (144p)" + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D20615%3Bdur%3D9.008%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1660945709376822/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgQgcXtjoYfO5P2A_V247zBsOdlBLyV5PjgBCu9HYqoaACIQDpBxxU-ltTF1ikH36r8t4zLQzS8-ijGUN6qgkrsmxepw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgUwqIEq6_6n7j9n8Pg5Uyqz4HGjW25JDtibdnDjkVf5ICIAd24x0_6OHE1roEurSd_JHpolhfkwSPSfFj4hl3K4Er/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 76.881, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 192, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 76.881, + "format": "269 - 192x144" + }, + { + "asr": null, + "filesize": 20615, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 18.308, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=160&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=20615&dur=9.008&lmt=1660945709376822&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgDt-H-tbSs2WhIKvGQQhW9VEafRKkwQWS0W3NM6utadsCIQDUenqVpa4s1i8Bn_BGyTTfmpkRN5z8GgAF3Nw-1v4ewQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 192, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 18.308, + "format": "160 - 192x144 (144p)" + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D62495%3Bdur%3D9.009%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1660945834968282/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4437434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgBGuI3nookacol01lxttwW1wQly7NyU3xVdF5XvrXw78CIQCSdYDE7zYLc8ayT2mBudyZhVKmPldjtuAvQlXmnNQifw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgPXv-ptK9054Zrx8-AQvkhoU492LfrTDwnHuCwTUtniUCIDcFqkhXTInDcoHu9BmyiQCqqWI9cYdkPMgapL4fjLEz/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 121.361, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 192, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 121.361, + "format": "603 - 192x144" + }, + { + "asr": null, + "filesize": 62495, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 55.495, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=278&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=62495&dur=9.009&lmt=1660945834968282&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgdnBmkEjoPBnSfzzXd25s8qgngC7eX21qw3VMNKAwgmACIQC2vyhWg_QF6ygMsvTVjZr4u6Ij53pKW5r0heS3yNn1KA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 192, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "192x144", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 55.495, + "format": "278 - 192x144 (144p)" + }, + { + "asr": null, + "filesize": 43466, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 38.602, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=395&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=43466&dur=9.008&lmt=1660945757515296&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKAznEV8u9gdGIw-xnQZ5fPmsGnkzOfDllv9dCmxThj3AiAX9AU4BXvDNDkqaKb8fZc2fW3CVqtyAFs2m9VWFJnpGg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 320, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 38.602, + "format": "395 - 320x240 (240p)" + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D33314%3Bdur%3D9.008%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1660945693258184/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIga9TRNQyPULGWzOadayPTTJ2uaAzDpvEgsj8n3r4yc3MCIDI9doRyVEkW2Gl_zdnGZcuJ062cBUwSbMhSAFJOPNO-/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgUxcm641l-XXMfwfSIDn7LRN6TL-1E5Kn9AZb_3w1QJgCIFnUrvyEQgumKwuFpFLgJc6tYLBq2KpuKqsPT_R-OYQz/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 93.712, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 320, + "height": 240, + "vcodec": "avc1.4D400D", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 93.712, + "format": "229 - 320x240" + }, + { + "asr": null, + "filesize": 33314, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 29.586, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=133&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=33314&dur=9.008&lmt=1660945693258184&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgZ162f_shiHFiTqDbFlCcYIMSm50IqmFJskU_ilfkR7ICIQD0Yg9HTRFcLVWqlVFLH5KfcmcJIAPPL_JwTGz8-sUeRg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 320, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400D", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 29.586, + "format": "133 - 320x240 (240p)" + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D59447%3Bdur%3D9.009%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1660945831908022/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4437434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgGUWXjMnMwp3LrRwpFG_Amn59n4qRRqrZkPJsMICaxVwCIAHh2FLJkhxWFJmwuNUgxdj4ladXOsFvA0VkznJfb9qw/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgCEFL2MHdLtR9MnhJG4Ya9L94F7amV-RdcNKLf5pYPJgCIAu51bEfddZb1FmP0gceNYOaLo7sX4yWMTaVnMMQFGyV/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 137.419, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 320, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 137.419, + "format": "604 - 320x240" + }, + { + "asr": null, + "filesize": 59447, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 52.788, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=242&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=59447&dur=9.009&lmt=1660945831908022&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgKoX6qbjGA7NZBetcQjUknVjlYrY6fs7SQx19Ipo2ryECIQCf3lw-QBwAesWx-vSENGxsl7MKlnjSTsdfr0m6M24wQA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 320, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "320x240", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 52.788, + "format": "242 - 320x240 (240p)" + }, + { + "asr": null, + "filesize": 78099, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 69.359, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=396&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=78099&dur=9.008&lmt=1660946011095705&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALqMylxURjz4Af-zjLqhcEiN-TtHmLesXrW1-VKUpBKbAiAQ2snXUz8_MBZV_El2swlMq96USDuG-Pc6562ddyrruw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 480, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 69.359, + "format": "396 - 480x360 (360p)" + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D57150%3Bdur%3D9.008%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1660945693864671/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4432434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAPlimmuPQAqCjvImM628y5Sev-s8IFlmIgHgvnyQ0urvAiEAjoo90EgG2S6aTxGczIIpc62o3EuyLjj0wCISvVpD24c%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgQRUECkdnB3631r6qXINyt7T85eoNkODlEAs3-inoQmUCICJHYqCoDq4V8bL0_BOgh29FdfFzpOZFCLNOJl3askgj/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 204.883, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 480, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 204.883, + "format": "230 - 480x360" + }, + { + "asr": null, + "filesize": 57150, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 50.754, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=134&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=57150&dur=9.008&lmt=1660945693864671&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJA0OjKaooY1Q5QF7gE_cYk1KuZVIgQvuqZY3kWE1NJAAiEA-0Sws-x-LUUuhgRWR9BkpRHs28zAQ2c3UUTmvvHEWgs%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 480, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": "SDR", + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 50.754, + "format": "134 - 480x360 (360p)" + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 180.939, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=18&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=9.055&lmt=1665508348849369&mt=1694796392&fvip=5&fexp=24007246&beids=24350017&c=ANDROID&txp=4438434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALn143d2vS16xd_ndXj_rB8QOeHSCHC9YxSeOaRMF9eWAiAaYxqrRyV5bREBHLPCrs8Wk8Msm3hJrj11OJc2RIEyzw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": 480, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": "SDR", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "filesize_approx": 208441, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "vbr": null, + "abr": null, + "format": "18 - 480x360 (360p)" + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D104373%3Bdur%3D9.009%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1660945832037331/hls_chunk_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31,29/mn/sn-5hne6nzk,sn-5hnednss/ms/au,rdu/mv/m/mvi/2/pl/22/initcwndbps/1868750/vprv/1/playlist_type/DVR/dover/13/txp/4437434/mt/1694796392/fvip/5/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgDUcdPIHNGJ4aEjIJdBVww0ROsh1PbMeCJwTE0CgnimUCIQDFx4dtEEiTS_m2NlDSSPD-kxF0RyhVQPRE2E5E0LSg0g%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhALtfpQjfbQMYbxHz0bqM35Iu7blF-YOjrL5X58URVXkNAiEAh4_Ps_0f8rK_EHkHK0BpnrNLNrjBxIUYJafgM3kn1XI%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1694818322/ei/sosEZcmcMdGVgQeatIDABA/ip/45.93.75.130/id/aba128441bdd54f4/source/youtube/requiressl/yes/playback_host/rr2---sn-5hne6nzk.googlevideo.com/mh/6Q/mm/31%2C29/mn/sn-5hne6nzk%2Csn-5hnednss/ms/au%2Crdu/mv/m/mvi/2/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1868750/vprv/1/go/1/mt/1694796392/fvip/5/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRgIhAPjgJ5L6c_sfFyR2FFR1BaUJo8RuGjhA9_1wOND8AO3GAiEA41COxaqltPNEco0cXZsJoBy8GMUqP95BLUdTfJRNRJE%3D/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRgIhANAG8hyS_myLc16a28Hj3fv5g_3IRWYVGBTZmhXL6wniAiEAvOFf1xoAySEDlUMNJ9ir0-sz4nZ6niJSZVtxlw78e5o%3D/file/index.m3u8", + "tbr": 265.96, + "ext": "mp4", + "fps": 30.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 480, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "source_preference": -1, + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "mp4", + "audio_ext": "none", + "abr": 0, + "vbr": 265.96, + "format": "605 - 480x360" + }, + { + "asr": null, + "filesize": 104373, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 92.683, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=243&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=104373&dur=9.009&lmt=1660945832037331&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMLnlCaLvJ2scyVr6qYrCp3rzn_Op9eerIVWyp62NXKIAiEAnswRfxH5KssHQAKETF2MPncVWX_eDgpTXBEHN589-Xo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 480, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 92.683, + "format": "243 - 480x360 (360p)" + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/3.jpg", + "preference": -37, + "id": "0" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/3.webp", + "preference": -36, + "id": "1" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/2.jpg", + "preference": -35, + "id": "2" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/2.webp", + "preference": -34, + "id": "3" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/1.jpg", + "preference": -33, + "id": "4" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/1.webp", + "preference": -32, + "id": "5" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mq3.jpg", + "preference": -31, + "id": "6" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/mq3.webp", + "preference": -30, + "id": "7" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mq2.jpg", + "preference": -29, + "id": "8" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/mq2.webp", + "preference": -28, + "id": "9" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mq1.jpg", + "preference": -27, + "id": "10" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/mq1.webp", + "preference": -26, + "id": "11" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hq3.jpg", + "preference": -25, + "id": "12" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hq3.webp", + "preference": -24, + "id": "13" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hq2.jpg", + "preference": -23, + "id": "14" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hq2.webp", + "preference": -22, + "id": "15" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hq1.jpg", + "preference": -21, + "id": "16" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hq1.webp", + "preference": -20, + "id": "17" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/sd3.jpg", + "preference": -19, + "id": "18" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/sd3.webp", + "preference": -18, + "id": "19" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/sd2.jpg", + "preference": -17, + "id": "20" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/sd2.webp", + "preference": -16, + "id": "21" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/sd1.jpg", + "preference": -15, + "id": "22" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/sd1.webp", + "preference": -14, + "id": "23" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/default.jpg", + "preference": -13, + "id": "24" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/default.jpg?sqp=-oaymwEkCHgQWvKriqkDGvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLA2OGwTh8r-rrPHmMqaeS0RNRrFaQ", + "height": 90, + "width": 120, + "preference": -13, + "id": "25", + "resolution": "120x90" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/default.webp", + "preference": -12, + "id": "26" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mqdefault.jpg", + "preference": -11, + "id": "27" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/mqdefault.jpg?sqp=-oaymwEmCMACELQB8quKqQMa8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAzOKoJNw88BUkuLNLP9QDOp1ZYpQ", + "height": 180, + "width": 320, + "preference": -11, + "id": "28", + "resolution": "320x180" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/mqdefault.webp", + "preference": -10, + "id": "29" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/0.jpg", + "preference": -9, + "id": "30" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/0.webp", + "preference": -8, + "id": "31" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg", + "preference": -7, + "id": "32" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAJYg16HMBdEsv9lYBJyNqA5G3anQ", + "height": 94, + "width": 168, + "preference": -7, + "id": "33", + "resolution": "168x94" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAgCNP9UuQas-D59hHHM-RqkUvA6g", + "height": 110, + "width": 196, + "preference": -7, + "id": "34", + "resolution": "196x110" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCTWaY5897XxhcpRyVtGQQNuMHfTg", + "height": 138, + "width": 246, + "preference": -7, + "id": "35", + "resolution": "246x138" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCeS6NC75yTYvyP4DsehZ3oXNuxMQ", + "height": 188, + "width": 336, + "preference": -7, + "id": "36", + "resolution": "336x188" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEmCOADEOgC8quKqQMa8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLDrmOZxhdEtPlKJawitTPTH-Zfe9Q", + "height": 360, + "width": 480, + "preference": -7, + "id": "37", + "resolution": "480x360" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hqdefault.webp", + "preference": -6, + "id": "38" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/sddefault.jpg", + "preference": -5, + "id": "39" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/sddefault.webp", + "preference": -4, + "id": "40" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hq720.jpg", + "preference": -3, + "id": "41" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/hq720.webp", + "preference": -2, + "id": "42" + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/maxresdefault.jpg", + "preference": -1, + "id": "43" + }, + { + "url": "https://i.ytimg.com/vi_webp/q6EoRBvdVPQ/maxresdefault.webp", + "preference": 0, + "id": "44" + } + ], + "thumbnail": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEmCOADEOgC8quKqQMa8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLDrmOZxhdEtPlKJawitTPTH-Zfe9Q", + "description": "Dinosauri bisesti dalle voci funeste. Original title: \"I dinosauri antropomorfi hanno il sangue nel ritmo\" (literally \"The anthropomorphic dinosaurs have blood in their rhythm\"), as opposite to http://youtu.be/e6MLjaKhp5U\nIf you are that \"wtf this is so funny i want to know shit\"-type of person, here's some info/faq:\n- original cartoon: https://youtu.be/brLqiYj5lQw\n- original song: http://youtu.be/v_XJIsDJgXc?t=4m1s (at 4:01)\n- original yee: https://youtu.be/hCKQP9IHHcA\n- \"is this a remix?\" sort of. i arranged the dinos so that they would sing along with the music and that's it\n- \"why does the dino say yee?\" in the original footage he was calling the other dinosaur by name (\"Peek\")\n- \"fuck everything! i went through the whole movie and there was no yee sound! i hate my life\" dude, yee is only in the italian version of the movie, ciao ciao pizza ferrari\n- \"is the italian dub as atrocious as the english one?\" you bet\n- \"why did you make this video?\" i was trying to make a burrito out of your stupid questions and this happened\n- \"how did this become so popular?\" illuminati", + "channel_id": "UC-fD_qwTEQQ1L-MUWx_mNvg", + "channel_url": "https://www.youtube.com/channel/UC-fD_qwTEQQ1L-MUWx_mNvg", + "duration": 9, + "view_count": 96269118, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=q6EoRBvdVPQ", + "categories": ["Comedy"], + "tags": [ + "voci", + "ambigue", + "antigue", + "antiche", + "cantiche", + "canzone", + "anthropomorphic dinosaurs", + "reddit", + "tumblr", + "dino", + "dinosaur", + "dingo pictures", + "phoenix games", + "meme", + "important videos", + "playlist" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 76000, + "chapters": null, + "heatmap": [], + "like_count": 1501556, + "channel": "revergo", + "channel_follower_count": 64500, + "uploader": "revergo", + "uploader_id": "@revergo", + "uploader_url": "https://www.youtube.com/@revergo", + "upload_date": "20120229", + "availability": "public", + "original_url": "https://www.youtube.com/watch?v=q6EoRBvdVPQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube", + "playlist": null, + "playlist_index": null, + "display_id": "q6EoRBvdVPQ", + "fulltitle": "Yee", + "duration_string": "9", + "is_live": false, + "was_live": false, + "requested_subtitles": null, + "_has_drm": null, + "epoch": 1694796723, + "requested_formats": [ + { + "asr": null, + "filesize": 104373, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 30, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 92.683, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=243&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=104373&dur=9.009&lmt=1660945832037331&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMLnlCaLvJ2scyVr6qYrCp3rzn_Op9eerIVWyp62NXKIAiEAnswRfxH5KssHQAKETF2MPncVWX_eDgpTXBEHN589-Xo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D", + "width": 480, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": "SDR", + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "480x360", + "aspect_ratio": 1.33, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "video_ext": "webm", + "audio_ext": "none", + "abr": 0, + "vbr": 92.683, + "format": "243 - 480x360 (360p)" + }, + { + "asr": 48000, + "filesize": 123202, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 109.257, + "url": "https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=251&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=123202&dur=9.021&lmt=1660945472183333&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&beids=24350017&c=ANDROID&txp=4432434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMvJsObaszrBWjSOzW_wD0jXOBJTmZsU0WpEG2pSqXaHAiBxmFJUftWY3sPtSdbaSoTYHfdHxOYHupAA85TROnKjIg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D", + "width": null, + "language": "es", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + }, + "protocol": "https", + "resolution": "audio only", + "aspect_ratio": null, + "http_headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-us,en;q=0.5", + "Sec-Fetch-Mode": "navigate" + }, + "audio_ext": "webm", + "video_ext": "none", + "vbr": 0, + "abr": 109.257, + "format": "251 - audio only (medium)" + } + ], + "format": "243 - 480x360 (360p)+251 - audio only (medium)", + "format_id": "243+251", + "ext": "webm", + "protocol": "https+https", + "language": "es", + "format_note": "360p+medium", + "filesize_approx": 227575, + "tbr": 201.94, + "width": 480, + "height": 360, + "resolution": "480x360", + "fps": 30, + "dynamic_range": "SDR", + "vcodec": "vp09.00.21.08", + "vbr": 92.683, + "stretched_ratio": null, + "aspect_ratio": 1.33, + "acodec": "opus", + "abr": 109.257, + "asr": 48000, + "audio_channels": 2 +} diff --git a/tests/components/media_extractor/snapshots/test_init.ambr b/tests/components/media_extractor/snapshots/test_init.ambr new file mode 100644 index 00000000000..571b64df914 --- /dev/null +++ b/tests/components/media_extractor/snapshots/test_init.ambr @@ -0,0 +1,120 @@ +# serializer version: 1 +# name: test_no_target_entity + ReadOnlyDict({ + 'device_id': list([ + 'fb034c3a9fefe47c584c32a6b51817eb', + ]), + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694794256/ei/sC0EZYCPHbuZx_AP3bGz0Ac/ip/84.31.234.146/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr2---sn-5hnekn7k.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hnekn7k,sn-5hne6nzy/ms/au,rdu/mv/m/mvi/2/pl/14/initcwndbps/2267500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694772337/fvip/3/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350018/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAIC0iobMnRschmQ3QaYsytXg9eg7l9B_-UNvMciis4bmAiEAg-3jr6SwOfAGCCU-JyTyxcXmraug-hPcjjJzm__43ug%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAOlqbgmuueNhIuGENYKCsdwiNAUPheXw-RMUqsiaB7YuAiANN43FxJl14Ve_H_c9K-aDoXG4sI7PDCqKDhov6Qro_g%3D%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://soundcloud.com/bruttoband/brutto-11-AUDIO-audio_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'AUDIO', + }) +# --- +# name: test_play_media_service[https://soundcloud.com/bruttoband/brutto-11-AUDIO-empty_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'AUDIO', + }) +# --- +# name: test_play_media_service[https://soundcloud.com/bruttoband/brutto-11-VIDEO-audio_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://soundcloud.com/bruttoband/brutto-11-VIDEO-empty_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://test.com/abc-AUDIO-audio_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk5MTc5fX19XX0_&Signature=JtF8BXxTCElhjCrhnSAq3W6z960VmdVXx7BPhQvI0MCxr~J43JFGO8CVw9-VBM2oEf14mqWo63-C0FO29DvUuBZnmLD3dhDfryVfWJsrix7voimoRDaNFE~3zntDbg7O2S8uWYyZK8OZC9anzwokvjH7jbmviWqK4~2IM9dwgejGgzrQU1aadV2Yro7NJZnF7SD~7tVjkM-hBg~X5zDYVxmGrdzN3tFoLwRmUch6RNDL~1DcWBk0AveBKQFAdBrFBjDDUeIyDz9Idhw2aG9~fjfckcf95KwqrVQxz1N5XEzfNDDo8xkUgDt0eb9dtXdwxLJ0swC6e5VLS8bsH91GMg__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'AUDIO', + }) +# --- +# name: test_play_media_service[https://test.com/abc-AUDIO-empty_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + 'media_content_type': 'AUDIO', + }) +# --- +# name: test_play_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ-VIDEO-audio_media_extractor_config-] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ-VIDEO-audio_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ-VIDEO-empty_media_extractor_config-] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_play_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ-VIDEO-empty_media_extractor_config] + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8', + 'media_content_type': 'VIDEO', + }) +# --- +# name: test_playlist + ReadOnlyDict({ + 'entity_id': 'media_player.bedroom', + 'extra': dict({ + }), + 'media_content_id': 'https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=243&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=104373&dur=9.009&lmt=1660945832037331&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMLnlCaLvJ2scyVr6qYrCp3rzn_Op9eerIVWyp62NXKIAiEAnswRfxH5KssHQAKETF2MPncVWX_eDgpTXBEHN589-Xo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D', + 'media_content_type': 'VIDEO', + }) +# --- diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py new file mode 100644 index 00000000000..c60f67031cf --- /dev/null +++ b/tests/components/media_extractor/test_init.py @@ -0,0 +1,211 @@ +"""The tests for Media Extractor integration.""" +from typing import Any +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from yt_dlp import DownloadError + +from homeassistant.components.media_extractor import DOMAIN +from homeassistant.components.media_player import SERVICE_PLAY_MEDIA +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component + +from tests.common import load_json_object_fixture +from tests.components.media_extractor import ( + YOUTUBE_EMPTY_PLAYLIST, + YOUTUBE_PLAYLIST, + YOUTUBE_VIDEO, + MockYoutubeDL, +) +from tests.components.media_extractor.const import NO_FORMATS_RESPONSE, SOUNDCLOUD_TRACK + + +async def test_play_media_service_is_registered(hass: HomeAssistant) -> None: + """Test play media service is registered.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_PLAY_MEDIA) + + +@pytest.mark.parametrize( + "config_fixture", ["empty_media_extractor_config", "audio_media_extractor_config"] +) +@pytest.mark.parametrize( + ("media_content_id", "media_content_type"), + [ + (YOUTUBE_VIDEO, "VIDEO"), + (SOUNDCLOUD_TRACK, "AUDIO"), + (NO_FORMATS_RESPONSE, "AUDIO"), + ], +) +async def test_play_media_service( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + calls: list[ServiceCall], + snapshot: SnapshotAssertion, + request: pytest.FixtureRequest, + config_fixture: str, + media_content_id: str, + media_content_type: str, +) -> None: + """Test play media service is registered.""" + config: dict[str, Any] = request.getfixturevalue(config_fixture) + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": media_content_type, + "media_content_id": media_content_id, + }, + ) + await hass.async_block_till_done() + + assert calls[0].data == snapshot + + +async def test_download_error( + hass: HomeAssistant, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling DownloadError.""" + + with patch( + "homeassistant.components.media_extractor.YoutubeDL.extract_info", + side_effect=DownloadError("Message"), + ): + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_VIDEO, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert f"Could not retrieve data for the URL: {YOUTUBE_VIDEO}" in caplog.text + + +async def test_no_target_entity( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + snapshot: SnapshotAssertion, +) -> None: + """Test having no target entity.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "device_id": "fb034c3a9fefe47c584c32a6b51817eb", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_VIDEO, + }, + ) + await hass.async_block_till_done() + + assert calls[0].data == snapshot + + +async def test_playlist( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + snapshot: SnapshotAssertion, +) -> None: + """Test extracting a playlist.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_PLAYLIST, + }, + ) + await hass.async_block_till_done() + + assert calls[0].data == snapshot + + +async def test_playlist_no_entries( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test extracting a playlist without entries.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_EMPTY_PLAYLIST, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert ( + f"Could not retrieve data for the URL: {YOUTUBE_EMPTY_PLAYLIST}" in caplog.text + ) + + +async def test_query_error( + hass: HomeAssistant, + empty_media_extractor_config: dict[str, Any], + calls: list[ServiceCall], +) -> None: + """Test handling error with query.""" + + with patch( + "homeassistant.components.media_extractor.YoutubeDL.extract_info", + return_value=load_json_object_fixture("media_extractor/youtube_1_info.json"), + ), patch( + "homeassistant.components.media_extractor.YoutubeDL.process_ie_result", + side_effect=DownloadError("Message"), + ): + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.bedroom", + "media_content_type": "VIDEO", + "media_content_id": YOUTUBE_VIDEO, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 From 781bc5b3bc0db10e994eff2fe66f6fa115f45c08 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 23 Sep 2023 12:04:44 -0700 Subject: [PATCH 722/984] Add tests for fitbit integration (#100765) * Add tests for fitbit integration * Update coveragerc * Update test requirements --- .coveragerc | 1 - homeassistant/components/fitbit/const.py | 2 + requirements_test_all.txt | 3 + tests/components/fitbit/__init__.py | 1 + tests/components/fitbit/conftest.py | 167 +++++++++++++++++++++++ tests/components/fitbit/test_sensor.py | 92 +++++++++++++ 6 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 tests/components/fitbit/__init__.py create mode 100644 tests/components/fitbit/conftest.py create mode 100644 tests/components/fitbit/test_sensor.py diff --git a/.coveragerc b/.coveragerc index d9182594356..12095eef247 100644 --- a/.coveragerc +++ b/.coveragerc @@ -389,7 +389,6 @@ omit = homeassistant/components/firmata/pin.py homeassistant/components/firmata/sensor.py homeassistant/components/firmata/switch.py - homeassistant/components/fitbit/* homeassistant/components/fivem/__init__.py homeassistant/components/fivem/binary_sensor.py homeassistant/components/fivem/coordinator.py diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 1578359356d..045b58cfc5e 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -12,6 +12,8 @@ from homeassistant.const import ( UnitOfVolume, ) +DOMAIN: Final = "fitbit" + ATTR_ACCESS_TOKEN: Final = "access_token" ATTR_REFRESH_TOKEN: Final = "refresh_token" ATTR_LAST_SAVED_AT: Final = "last_saved_at" diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c469e25e54..ce69841233b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -629,6 +629,9 @@ feedparser==6.0.10 # homeassistant.components.file file-read-backwards==2.0.0 +# homeassistant.components.fitbit +fitbit==0.3.1 + # homeassistant.components.fivem fivem-api==0.1.2 diff --git a/tests/components/fitbit/__init__.py b/tests/components/fitbit/__init__.py new file mode 100644 index 00000000000..0b639a3faa8 --- /dev/null +++ b/tests/components/fitbit/__init__.py @@ -0,0 +1 @@ +"""Tests for fitbit component.""" diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py new file mode 100644 index 00000000000..e3e5bbd1d18 --- /dev/null +++ b/tests/components/fitbit/conftest.py @@ -0,0 +1,167 @@ +"""Test fixtures for fitbit.""" + +from collections.abc import Awaitable, Callable, Generator +import datetime +from http import HTTPStatus +import time +from typing import Any +from unittest.mock import patch + +import pytest +from requests_mock.mocker import Mocker + +from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +PROFILE_USER_ID = "fitbit-api-user-id-1" +FAKE_TOKEN = "some-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" + +PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json" +DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json" +TIMESERIES_API_URL_FORMAT = ( + "https://api.fitbit.com/1/user/-/{resource}/date/today/7d.json" +) + + +@pytest.fixture(name="token_expiration_time") +def mcok_token_expiration_time() -> float: + """Fixture for expiration time of the config entry auth token.""" + return time.time() + 86400 + + +@pytest.fixture(name="fitbit_config_yaml") +def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any]: + """Fixture for the yaml fitbit.conf file contents.""" + return { + "access_token": FAKE_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "last_saved_at": token_expiration_time, + } + + +@pytest.fixture(name="fitbit_config_setup", autouse=True) +def mock_fitbit_config_setup( + fitbit_config_yaml: dict[str, Any], +) -> Generator[None, None, None]: + """Fixture to mock out fitbit.conf file data loading and persistence.""" + + with patch( + "homeassistant.components.fitbit.sensor.os.path.isfile", return_value=True + ), patch( + "homeassistant.components.fitbit.sensor.load_json_object", + return_value=fitbit_config_yaml, + ), patch( + "homeassistant.components.fitbit.sensor.save_json", + ): + yield + + +@pytest.fixture(name="monitored_resources") +def mock_monitored_resources() -> list[str] | None: + """Fixture for the fitbit yaml config monitored_resources field.""" + return None + + +@pytest.fixture(name="sensor_platform_config") +def mock_sensor_platform_config( + monitored_resources: list[str] | None, +) -> dict[str, Any]: + """Fixture for the fitbit sensor platform configuration data in configuration.yaml.""" + config = {} + if monitored_resources is not None: + config["monitored_resources"] = monitored_resources + return config + + +@pytest.fixture(name="sensor_platform_setup") +async def mock_sensor_platform_setup( + hass: HomeAssistant, + sensor_platform_config: dict[str, Any], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + + async def run() -> bool: + result = await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": DOMAIN, + **sensor_platform_config, + } + ] + }, + ) + await hass.async_block_till_done() + return result + + return run + + +@pytest.fixture(name="profile_id") +async def mock_profile_id() -> str: + """Fixture for the profile id returned from the API response.""" + return PROFILE_USER_ID + + +@pytest.fixture(name="profile", autouse=True) +async def mock_profile(requests_mock: Mocker, profile_id: str) -> 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": "en_US", + }, + }, + ) + + +@pytest.fixture(name="devices_response") +async def mock_device_response() -> list[dict[str, Any]]: + """Return the list of devices.""" + return [] + + +@pytest.fixture(autouse=True) +async def mock_devices(requests_mock: Mocker, devices_response: dict[str, Any]) -> None: + """Fixture to setup fake device responses.""" + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + status_code=HTTPStatus.OK, + json=devices_response, + ) + + +def timeseries_response(resource: str, value: str) -> dict[str, Any]: + """Create a timeseries response value.""" + return { + resource: [{"dateTime": datetime.datetime.today().isoformat(), "value": value}] + } + + +@pytest.fixture(name="register_timeseries") +async def mock_register_timeseries( + requests_mock: Mocker, +) -> Callable[[str, dict[str, Any]], None]: + """Fixture to setup fake timeseries API responses.""" + + def register(resource: str, response: dict[str, Any]) -> None: + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource=resource), + status_code=HTTPStatus.OK, + json=response, + ) + + return register diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py new file mode 100644 index 00000000000..6918a712f72 --- /dev/null +++ b/tests/components/fitbit/test_sensor.py @@ -0,0 +1,92 @@ +"""Tests for the fitbit sensor platform.""" + + +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from .conftest import timeseries_response + +DEVICE_RESPONSE_CHARGE_2 = { + "battery": "Medium", + "batteryLevel": 60, + "deviceVersion": "Charge 2", + "id": "816713257", + "lastSyncTime": "2019-11-07T12:00:58.000", + "mac": "16ADD56D54GD", + "type": "TRACKER", +} +DEVICE_RESPONSE_ARIA_AIR = { + "battery": "High", + "batteryLevel": 95, + "deviceVersion": "Aria Air", + "id": "016713257", + "lastSyncTime": "2019-11-07T12:00:58.000", + "mac": "06ADD56D54GD", + "type": "SCALE", +} + + +@pytest.mark.parametrize( + "monitored_resources", + [["activities/steps"]], +) +async def test_step_sensor( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], +) -> None: + """Test battery level sensor.""" + + register_timeseries( + "activities/steps", timeseries_response("activities-steps", "5600") + ) + await sensor_platform_setup() + + state = hass.states.get("sensor.steps") + assert state + assert state.state == "5600" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Steps", + "icon": "mdi:walk", + "unit_of_measurement": "steps", + } + + +@pytest.mark.parametrize( + ("devices_response", "monitored_resources"), + [([DEVICE_RESPONSE_CHARGE_2, DEVICE_RESPONSE_ARIA_AIR], ["devices/battery"])], +) +async def test_device_battery_level( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test battery level sensor for devices.""" + + await sensor_platform_setup() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "Medium" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Charge 2 Battery", + "icon": "mdi:battery-50", + "model": "Charge 2", + "type": "tracker", + } + + state = hass.states.get("sensor.aria_air_battery") + assert state + assert state.state == "High" + assert state.attributes == { + "attribution": "Data provided by Fitbit.com", + "friendly_name": "Aria Air Battery", + "icon": "mdi:battery", + "model": "Aria Air", + "type": "scale", + } From ba6a92756af67a3bd42f8ec79e65b409cdd276ea Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 23 Sep 2023 21:05:53 +0200 Subject: [PATCH 723/984] Call async added to hass super in Flo (#100453) --- homeassistant/components/flo/switch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 4456732d125..5be0ffb745d 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -103,6 +103,7 @@ class FloSwitch(FloEntity, SwitchEntity): # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" + await super().async_added_to_hass() self.async_on_remove(self._device.async_add_listener(self.async_update_state)) async def async_set_mode_home(self): From 1fce60bd6fe590b895ee9949ffa355346d509e0e Mon Sep 17 00:00:00 2001 From: sdb9696 <51370195+sdb9696@users.noreply.github.com> Date: Sat, 23 Sep 2023 20:21:34 +0100 Subject: [PATCH 724/984] Bump ring-doorbell to 0.7.3 (#100688) Bump ring to 0.7.3 --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 355c630272e..0b5198f36d3 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell==0.7.2"] + "requirements": ["ring-doorbell==0.7.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 21ed0fb403d..2e71011cda6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2313,7 +2313,7 @@ rfk101py==0.0.1 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.7.2 +ring-doorbell==0.7.3 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce69841233b..f6b8a315835 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1715,7 +1715,7 @@ reolink-aio==0.7.10 rflink==0.0.65 # homeassistant.components.ring -ring-doorbell==0.7.2 +ring-doorbell==0.7.3 # homeassistant.components.roku rokuecp==0.18.1 From 4a86892d82c254600acd8f70b90e090d1eea9ac4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 23 Sep 2023 22:49:08 +0200 Subject: [PATCH 725/984] Fix fitbit test code owner (#100772) --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index e1afc58ae98..6874e81bf0a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -391,6 +391,7 @@ build.json @home-assistant/supervisor /homeassistant/components/firmata/ @DaAwesomeP /tests/components/firmata/ @DaAwesomeP /homeassistant/components/fitbit/ @allenporter +/tests/components/fitbit/ @allenporter /homeassistant/components/fivem/ @Sander0542 /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus From 06ade747117ace45f2d2f27a0a4b30f6a2297838 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 23 Sep 2023 23:01:08 +0200 Subject: [PATCH 726/984] Bump pysensibo 1.0.35 (#100245) * Bump pysensibo 1.0.34 * 1.0.35 * Mod tests * revert refactoring * Fix tests --- .../components/sensibo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sensibo/fixtures/data.json | 4 ++-- tests/components/sensibo/test_climate.py | 23 +++++++++++++------ tests/components/sensibo/test_sensor.py | 2 +- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 42964ddce8f..016b3a1e9d9 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -15,5 +15,5 @@ "iot_class": "cloud_polling", "loggers": ["pysensibo"], "quality_scale": "platinum", - "requirements": ["pysensibo==1.0.33"] + "requirements": ["pysensibo==1.0.35"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2e71011cda6..e038c435aca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2001,7 +2001,7 @@ pysaj==0.0.16 pyschlage==2023.9.1 # homeassistant.components.sensibo -pysensibo==1.0.33 +pysensibo==1.0.35 # homeassistant.components.serial # homeassistant.components.zha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6b8a315835..21065157095 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1502,7 +1502,7 @@ pysabnzbd==1.1.1 pyschlage==2023.9.1 # homeassistant.components.sensibo -pysensibo==1.0.33 +pysensibo==1.0.35 # homeassistant.components.serial # homeassistant.components.zha diff --git a/tests/components/sensibo/fixtures/data.json b/tests/components/sensibo/fixtures/data.json index 8be6d1e173a..96657df50d3 100644 --- a/tests/components/sensibo/fixtures/data.json +++ b/tests/components/sensibo/fixtures/data.json @@ -608,7 +608,7 @@ "isGeofenceOnExitEnabled": false, "isClimateReactGeofenceOnExitEnabled": false, "isMotionGeofenceOnExitEnabled": false, - "serial": "0987654321", + "serial": "0987654329", "sensorsCalibration": { "temperature": 0.0, "humidity": 0.0 @@ -699,7 +699,7 @@ "ssid": "Sensibo-09876", "password": null }, - "macAddress": "00:01:00:01:00:01", + "macAddress": "00:03:00:03:00:03", "autoOffMinutes": null, "autoOffEnabled": false, "antiMoldTimer": null, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 688a373b8f0..52b22570957 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -90,18 +90,26 @@ async def test_climate( assert state1.state == "heat" assert state1.attributes == { "hvac_modes": [ - "heat_cool", "cool", - "dry", - "fan_only", "heat", + "dry", + "heat_cool", + "fan_only", "off", ], "min_temp": 10, "max_temp": 20, "target_temp_step": 1, - "fan_modes": ["low", "medium", "quiet"], - "swing_modes": ["fixedmiddletop", "fixedtop", "stopped"], + "fan_modes": [ + "quiet", + "low", + "medium", + ], + "swing_modes": [ + "stopped", + "fixedtop", + "fixedmiddletop", + ], "current_temperature": 21.2, "temperature": 25, "current_humidity": 32.9, @@ -113,13 +121,14 @@ async def test_climate( assert state2.state == "off" - assert not state3 + assert state3 + assert state3.state == "off" found_log = False logs = caplog.get_records("setup") for log in logs: if ( log.message - == "Device Bedroom not correctly registered with Sensibo cloud. Skipping device" + == "Device Bedroom not correctly registered with remote on Sensibo cloud." ): found_log = True break diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 24dbdef1fe3..0978b829608 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -18,7 +18,7 @@ async def test_sensor( hass: HomeAssistant, entity_registry_enabled_by_default: None, load_int: ConfigEntry, - monkeypatch: pytest.pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, ) -> None: """Test the Sensibo sensor.""" From 1f66fc013c5c1f8ae436102d9815c50e50596c8d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 23 Sep 2023 23:08:07 +0200 Subject: [PATCH 727/984] Add strong to fan mode for Sensibo (#100773) --- homeassistant/components/sensibo/climate.py | 10 +++++++++- homeassistant/components/sensibo/strings.json | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 3529627b497..f8ecd1b9b80 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -54,7 +54,15 @@ ATTR_HORIZONTAL_SWING_MODE = "horizontal_swing_mode" ATTR_LIGHT = "light" BOOST_INCLUSIVE = "boost_inclusive" -AVAILABLE_FAN_MODES = {"quiet", "low", "medium", "medium_high", "high", "auto"} +AVAILABLE_FAN_MODES = { + "quiet", + "low", + "medium", + "medium_high", + "high", + "strong", + "auto", +} AVAILABLE_SWING_MODES = { "stopped", "fixedtop", diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index a6f14b73ace..ddd164225fc 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -127,6 +127,7 @@ "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", "medium_high": "Medium high", + "strong": "Strong", "quiet": "Quiet" } }, @@ -211,6 +212,7 @@ "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", + "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]" } }, @@ -347,6 +349,7 @@ "fan_mode": { "state": { "quiet": "Quiet", + "strong": "Strong", "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", "medium_high": "Medium high", From 8d8c7187d3a52946048e63802094a9ab8684fdfb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 23 Sep 2023 14:14:57 -0700 Subject: [PATCH 728/984] Fix rainbird unique id (#99704) * Don't set a unique id for devices with no serial * Add additional check for the same config entry host/port when there is no serial * Update homeassistant/components/rainbird/config_flow.py Co-authored-by: Robert Resch * Update tests/components/rainbird/test_config_flow.py Co-authored-by: Robert Resch * Update tests/components/rainbird/test_config_flow.py Co-authored-by: Robert Resch --------- Co-authored-by: Robert Resch --- .../components/rainbird/config_flow.py | 9 +- tests/components/rainbird/conftest.py | 12 +- tests/components/rainbird/test_config_flow.py | 121 +++++++++++++++++- 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index a784e4623d6..bf6682e7a6f 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -125,8 +125,13 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): options: dict[str, Any], ) -> FlowResult: """Create the config entry.""" - await self.async_set_unique_id(serial_number) - self._abort_if_unique_id_configured() + # 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) 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 9e4e4e546cb..40b400210aa 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -35,6 +35,7 @@ SERIAL_NUMBER = 0x12635436566 # Get serial number Command 0x85. Serial is 0x12635436566 SERIAL_RESPONSE = "850000012635436566" +ZERO_SERIAL_RESPONSE = "850000000000000000" # Model and version command 0x82 MODEL_AND_VERSION_RESPONSE = "820006090C" # Get available stations command 0x83 @@ -84,6 +85,12 @@ def yaml_config() -> dict[str, Any]: return {} +@pytest.fixture +async def unique_id() -> str: + """Fixture for serial number used in the config entry.""" + return SERIAL_NUMBER + + @pytest.fixture async def config_entry_data() -> dict[str, Any]: """Fixture for MockConfigEntry data.""" @@ -92,13 +99,14 @@ async def config_entry_data() -> dict[str, Any]: @pytest.fixture async def config_entry( - config_entry_data: dict[str, Any] | None + config_entry_data: dict[str, Any] | None, + unique_id: str, ) -> MockConfigEntry | None: """Fixture for MockConfigEntry.""" if config_entry_data is None: return None return MockConfigEntry( - unique_id=SERIAL_NUMBER, + unique_id=unique_id, domain=DOMAIN, data=config_entry_data, options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index f11eba4fed7..e7337ad6508 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Generator from http import HTTPStatus +from typing import Any from unittest.mock import Mock, patch import pytest @@ -19,8 +20,11 @@ from .conftest import ( CONFIG_ENTRY_DATA, HOST, PASSWORD, + SERIAL_NUMBER, SERIAL_RESPONSE, URL, + ZERO_SERIAL_RESPONSE, + ComponentSetup, mock_response, ) @@ -66,19 +70,132 @@ async def complete_flow(hass: HomeAssistant) -> FlowResult: ) -async def test_controller_flow(hass: HomeAssistant, mock_setup: Mock) -> None: +@pytest.mark.parametrize( + ("responses", "expected_config_entry", "expected_unique_id"), + [ + ( + [mock_response(SERIAL_RESPONSE)], + CONFIG_ENTRY_DATA, + SERIAL_NUMBER, + ), + ( + [mock_response(ZERO_SERIAL_RESPONSE)], + {**CONFIG_ENTRY_DATA, "serial_number": 0}, + None, + ), + ], +) +async def test_controller_flow( + hass: HomeAssistant, + mock_setup: Mock, + expected_config_entry: dict[str, str], + expected_unique_id: int | None, +) -> None: """Test the controller is setup correctly.""" result = await complete_flow(hass) assert result.get("type") == "create_entry" assert result.get("title") == HOST assert "result" in result - assert result["result"].data == CONFIG_ENTRY_DATA + assert dict(result["result"].data) == expected_config_entry assert result["result"].options == {ATTR_DURATION: 6} + assert result["result"].unique_id == expected_unique_id assert len(mock_setup.mock_calls) == 1 +@pytest.mark.parametrize( + ( + "unique_id", + "config_entry_data", + "config_flow_responses", + "expected_config_entry", + ), + [ + ( + "other-serial-number", + {**CONFIG_ENTRY_DATA, "host": "other-host"}, + [mock_response(SERIAL_RESPONSE)], + CONFIG_ENTRY_DATA, + ), + ( + None, + {**CONFIG_ENTRY_DATA, "serial_number": 0, "host": "other-host"}, + [mock_response(ZERO_SERIAL_RESPONSE)], + {**CONFIG_ENTRY_DATA, "serial_number": 0}, + ), + ], + ids=["with-serial", "zero-serial"], +) +async def test_multiple_config_entries( + hass: HomeAssistant, + setup_integration: ComponentSetup, + 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 + + responses.clear() + responses.extend(config_flow_responses) + + result = await complete_flow(hass) + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert dict(result.get("result").data) == expected_config_entry + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + +@pytest.mark.parametrize( + ( + "unique_id", + "config_entry_data", + "config_flow_responses", + ), + [ + ( + SERIAL_NUMBER, + CONFIG_ENTRY_DATA, + [mock_response(SERIAL_RESPONSE)], + ), + ( + None, + {**CONFIG_ENTRY_DATA, "serial_number": 0}, + [mock_response(ZERO_SERIAL_RESPONSE)], + ), + ], + ids=[ + "duplicate-serial-number", + "duplicate-host-port-no-serial", + ], +) +async def test_duplicate_config_entries( + hass: HomeAssistant, + setup_integration: ComponentSetup, + 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 + + responses.clear() + responses.extend(config_flow_responses) + + result = await complete_flow(hass) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + async def test_controller_cannot_connect( hass: HomeAssistant, mock_setup: Mock, From 28dc17c0b30a6fc5fc8c82066ea6fec987dad9d9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 23 Sep 2023 23:37:02 +0200 Subject: [PATCH 729/984] Refactor Sensibo tests to use snapshot (#100775) --- .../sensibo/snapshots/test_climate.ambr | 33 +++++++++++++++++++ .../sensibo/snapshots/test_sensor.ambr | 26 +++++++++++++++ tests/components/sensibo/test_climate.py | 33 ++----------------- tests/components/sensibo/test_sensor.py | 25 +++----------- 4 files changed, 66 insertions(+), 51 deletions(-) create mode 100644 tests/components/sensibo/snapshots/test_climate.ambr create mode 100644 tests/components/sensibo/snapshots/test_sensor.ambr diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0a5a9d78b1b --- /dev/null +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_climate + ReadOnlyDict({ + 'current_humidity': 32.9, + 'current_temperature': 21.2, + 'fan_mode': 'high', + 'fan_modes': list([ + 'quiet', + 'low', + 'medium', + ]), + 'friendly_name': 'Hallway', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 20, + 'min_temp': 10, + 'supported_features': , + 'swing_mode': 'stopped', + 'swing_modes': list([ + 'stopped', + 'fixedtop', + 'fixedmiddletop', + ]), + 'target_temp_step': 1, + 'temperature': 25, + }) +# --- diff --git a/tests/components/sensibo/snapshots/test_sensor.ambr b/tests/components/sensibo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..4522071049d --- /dev/null +++ b/tests/components/sensibo/snapshots/test_sensor.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_sensor + ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Kitchen PM2.5', + 'icon': 'mdi:air-filter', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor.1 + ReadOnlyDict({ + 'device_class': 'temperature', + 'fanlevel': 'low', + 'friendly_name': 'Hallway Climate React low temperature threshold', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'state_class': , + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 52b22570957..530034720f2 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -6,6 +6,7 @@ from unittest.mock import patch from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from voluptuous import MultipleInvalid from homeassistant.components.climate import ( @@ -80,6 +81,7 @@ async def test_climate( caplog: pytest.LogCaptureFixture, get_data: SensiboData, load_int: ConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test the Sensibo climate.""" @@ -88,36 +90,7 @@ async def test_climate( state3 = hass.states.get("climate.bedroom") assert state1.state == "heat" - assert state1.attributes == { - "hvac_modes": [ - "cool", - "heat", - "dry", - "heat_cool", - "fan_only", - "off", - ], - "min_temp": 10, - "max_temp": 20, - "target_temp_step": 1, - "fan_modes": [ - "quiet", - "low", - "medium", - ], - "swing_modes": [ - "stopped", - "fixedtop", - "fixedmiddletop", - ], - "current_temperature": 21.2, - "temperature": 25, - "current_humidity": 32.9, - "fan_mode": "high", - "swing_mode": "stopped", - "friendly_name": "Hallway", - "supported_features": 41, - } + assert state1.attributes == snapshot assert state2.state == "off" diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 0978b829608..b3089c37e68 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -6,6 +6,7 @@ from unittest.mock import patch from pysensibo.model import SensiboData import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -20,6 +21,7 @@ async def test_sensor( load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, + snapshot: SnapshotAssertion, ) -> None: """Test the Sensibo sensor.""" @@ -31,27 +33,8 @@ async def test_sensor( assert state2.state == "1" assert state3.state == "n" assert state4.state == "0.0" - assert state2.attributes == { - "state_class": "measurement", - "unit_of_measurement": "µg/m³", - "device_class": "pm25", - "icon": "mdi:air-filter", - "friendly_name": "Kitchen PM2.5", - } - assert state4.attributes == { - "device_class": "temperature", - "friendly_name": "Hallway Climate React low temperature threshold", - "state_class": "measurement", - "unit_of_measurement": "°C", - "on": True, - "targettemperature": 21, - "temperatureunit": "c", - "mode": "heat", - "fanlevel": "low", - "swing": "stopped", - "horizontalswing": "stopped", - "light": "on", - } + assert state2.attributes == snapshot + assert state4.attributes == snapshot monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pm25", 2) From f8a8fe760d84ea5c830be4196435956ee1681e44 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 23 Sep 2023 18:03:07 -0400 Subject: [PATCH 730/984] Add config flow to Hydrawise (#95589) * Add config flow to Hydrawise * Raise an issue when a YAML config is detected * Add a test for YAML import * Add missing __init__.py * Update CODEOWNERS * Update requirements_test_all.txt * Add config flow data to strings.json * Hande scan_interval not being in YAML on import * Fix requirements * Update deprecation dates * Update requirements_test_all.txt * Changes from review * Update homeassistant/components/hydrawise/__init__.py Co-authored-by: G Johansson * Add already_configured to strings.json * Add back setup_platform functions * Apply suggestions from code review Co-authored-by: G Johansson * Add back setup_platform * Update requirements_test_all.txt * Run black on hydrawise/*.py * Add missing import of HOMEASSISTANT_DOMAIN * Use more specific errors in config flow * Add additional tests * Update config flow to use pydrawise.legacy * Re-work YAML deprecation issues * Revert some changes to binary_sensor, as requested in review * Changes requested during review * Apply suggestions from code review Co-authored-by: G Johansson * Remove unused STE_USER_DATA_SCHEMA Co-authored-by: G Johansson * Update comment in setup_platform * Re-work the config flow again * Apply suggestions from code review Co-authored-by: G Johansson * Update tests * Add back the _default_watering_timer attribute * Bump deprecation dates * Update requirements_test_all.txt * Update CODEOWNERS --------- Co-authored-by: G Johansson Co-authored-by: Joost Lekkerkerker --- .coveragerc | 7 +- CODEOWNERS | 1 + .../components/hydrawise/__init__.py | 62 +++-- .../components/hydrawise/binary_sensor.py | 38 ++-- .../components/hydrawise/config_flow.py | 111 +++++++++ homeassistant/components/hydrawise/const.py | 3 - .../components/hydrawise/manifest.json | 1 + homeassistant/components/hydrawise/sensor.py | 25 +- .../components/hydrawise/strings.json | 25 ++ homeassistant/components/hydrawise/switch.py | 27 ++- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/hydrawise/__init__.py | 1 + tests/components/hydrawise/conftest.py | 15 ++ .../components/hydrawise/test_config_flow.py | 213 ++++++++++++++++++ 16 files changed, 478 insertions(+), 57 deletions(-) create mode 100644 homeassistant/components/hydrawise/config_flow.py create mode 100644 homeassistant/components/hydrawise/strings.json create mode 100644 tests/components/hydrawise/__init__.py create mode 100644 tests/components/hydrawise/conftest.py create mode 100644 tests/components/hydrawise/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 12095eef247..752ea5ca7bc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -537,7 +537,12 @@ omit = homeassistant/components/hvv_departures/__init__.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py - homeassistant/components/hydrawise/* + homeassistant/components/hydrawise/__init__.py + homeassistant/components/hydrawise/binary_sensor.py + homeassistant/components/hydrawise/const.py + homeassistant/components/hydrawise/coordinator.py + homeassistant/components/hydrawise/sensor.py + homeassistant/components/hydrawise/switch.py homeassistant/components/ialarm/alarm_control_panel.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 6874e81bf0a..e728d70c1bc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -562,6 +562,7 @@ build.json @home-assistant/supervisor /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @dknowles2 @ptcryan +/tests/components/hydrawise/ @dknowles2 @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy /homeassistant/components/ialarm/ @RyuzakiKK diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 6d9f2747847..560046e9c2b 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -5,13 +5,19 @@ from pydrawise.legacy import LegacyHydrawise from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.components import persistent_notification -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_API_KEY, + CONF_SCAN_INTERVAL, + 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, NOTIFICATION_ID, NOTIFICATION_TITLE, SCAN_INTERVAL +from .const import DOMAIN, LOGGER, SCAN_INTERVAL from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( @@ -26,37 +32,49 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Hunter Hydrawise component.""" - conf = config[DOMAIN] - access_token = conf[CONF_ACCESS_TOKEN] - scan_interval = conf.get(CONF_SCAN_INTERVAL) + if DOMAIN not in config: + return True + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: config[DOMAIN][CONF_ACCESS_TOKEN]}, + ) + ) + return True + + +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(LegacyHydrawise, access_token) except (ConnectTimeout, HTTPError) as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - _show_failure_notification(hass, str(ex)) - return False + raise ConfigEntryNotReady( + f"Unable to connect to Hydrawise cloud service: {ex}" + ) from ex - if not hydrawise.current_controller: - LOGGER.error("Failed to fetch Hydrawise data") - _show_failure_notification(hass, "Failed to fetch Hydrawise data.") - return False - - hass.data[DOMAIN] = HydrawiseDataUpdateCoordinator(hass, hydrawise, scan_interval) + 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. - + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -def _show_failure_notification(hass: HomeAssistant, error: str) -> None: - persistent_notification.create( - hass, - f"Error: {error}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) +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/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 9298e605791..06683ff0345 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -38,6 +39,8 @@ BINARY_SENSOR_KEYS: list[str] = [ desc.key for desc in (BINARY_SENSOR_STATUS, *BINARY_SENSOR_TYPES) ] +# Deprecated since Home Assistant 2023.10.0 +# Can be removed completely in 2024.4.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSOR_KEYS): vol.All( @@ -54,32 +57,39 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: LegacyHydrawise = coordinator.api - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + # We don't need to trigger import flow from here as it's triggered from `__init__.py` + return - entities = [] - if BINARY_SENSOR_STATUS.key in monitored_conditions: - entities.append( - HydrawiseBinarySensor( - data=hydrawise.current_controller, - coordinator=coordinator, - description=BINARY_SENSOR_STATUS, - ) + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Hydrawise binary_sensor platform.""" + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + hydrawise: LegacyHydrawise = coordinator.api + + entities = [ + HydrawiseBinarySensor( + data=hydrawise.current_controller, + coordinator=coordinator, + description=BINARY_SENSOR_STATUS, ) + ] # create a sensor for each zone for zone in hydrawise.relays: for description in BINARY_SENSOR_TYPES: - if description.key not in monitored_conditions: - continue entities.append( HydrawiseBinarySensor( data=zone, coordinator=coordinator, description=description ) ) - add_entities(entities, True) + async_add_entities(entities) class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py new file mode 100644 index 00000000000..c4b37fb4a06 --- /dev/null +++ b/homeassistant/components/hydrawise/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for the Hydrawise integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from pydrawise import legacy +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN, LOGGER + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hydrawise.""" + + VERSION = 1 + + async def _create_entry( + self, api_key: str, *, on_failure: Callable[[str], FlowResult] + ) -> FlowResult: + """Create the config entry.""" + try: + api = await self.hass.async_add_executor_job( + legacy.LegacyHydrawise, api_key + ) + except ConnectTimeout: + return on_failure("timeout_connect") + except HTTPError as ex: + LOGGER.error("Unable to connect to Hydrawise cloud service: %s", ex) + return on_failure("cannot_connect") + + if not api.status: + return on_failure("unknown") + + await self.async_set_unique_id(f"hydrawise-{api.customer_id}") + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Hydrawise", data={CONF_API_KEY: api_key}) + + def _import_issue(self, error_type: str) -> FlowResult: + """Create an issue about a YAML import failure.""" + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_import_issue_{error_type}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="deprecated_yaml_import_issue", + translation_placeholders={"error_type": error_type}, + ) + return self.async_abort(reason=error_type) + + def _deprecated_yaml_issue(self) -> None: + """Create an issue about YAML deprecation.""" + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Hydrawise", + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial setup.""" + if user_input is not None: + api_key = user_input[CONF_API_KEY] + return await self._create_entry(api_key, on_failure=self._show_form) + return self._show_form() + + def _show_form(self, error_type: str | None = None) -> FlowResult: + errors = {} + if error_type is not None: + errors["base"] = error_type + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: + """Import data from YAML.""" + try: + result = await self._create_entry( + import_data.get(CONF_API_KEY, ""), + on_failure=self._import_issue, + ) + except AbortFlow: + self._deprecated_yaml_issue() + raise + + if result["type"] == FlowResultType.CREATE_ENTRY: + self._deprecated_yaml_issue() + return result diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 515fdaec2b1..ccf3eb5bac0 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -8,9 +8,6 @@ LOGGER = logging.getLogger(__package__) ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] CONF_WATERING_TIME = "watering_minutes" -NOTIFICATION_ID = "hydrawise_notification" -NOTIFICATION_TITLE = "Hydrawise Setup" - DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = 15 diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index f9de9bf30c9..eea4a0e2ebf 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -2,6 +2,7 @@ "domain": "hydrawise", "name": "Hunter Hydrawise", "codeowners": ["@dknowles2", "@ptcryan"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index fa82c058f5b..bcf178744c8 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,7 +1,6 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations -from pydrawise.legacy import LegacyHydrawise import voluptuous as vol from homeassistant.components.sensor import ( @@ -10,6 +9,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -37,6 +37,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] +# Deprecated since Home Assistant 2023.10.0 +# Can be removed completely in 2024.4.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( @@ -56,18 +58,25 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: LegacyHydrawise = coordinator.api - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + # We don't need to trigger import flow from here as it's triggered from `__init__.py` + return + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Hydrawise sensor platform.""" + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] entities = [ HydrawiseSensor(data=zone, coordinator=coordinator, description=description) - for zone in hydrawise.relays + for zone in coordinator.api.relays for description in SENSOR_TYPES - if description.key in monitored_conditions ] - - add_entities(entities, True) + async_add_entities(entities) class HydrawiseSensor(HydrawiseEntity, SensorEntity): diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json new file mode 100644 index 00000000000..50d3fbaf4c3 --- /dev/null +++ b/homeassistant/components/hydrawise/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "issues": { + "deprecated_yaml_import_issue": { + "title": "The Hydrawise YAML configuration import failed", + "description": "Configuring Hydrawise using YAML is being removed but there was an {error_type} error importing your YAML configuration.\n\nEnsure connection to Hydrawise works and restart Home Assistant to try again or remove the Hydrawise YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 0dd694a47d6..88112d8e27a 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Any -from pydrawise.legacy import LegacyHydrawise import voluptuous as vol from homeassistant.components.switch import ( @@ -12,6 +11,7 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -43,6 +43,8 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] +# Deprecated since Home Assistant 2023.10.0 +# Can be removed completely in 2024.4.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCH_KEYS): vol.All( @@ -62,10 +64,20 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: LegacyHydrawise = coordinator.api - monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS] - default_watering_timer: int = config[CONF_WATERING_TIME] + # We don't need to trigger import flow from here as it's triggered from `__init__.py` + return + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Hydrawise switch platform.""" + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + default_watering_timer = DEFAULT_WATERING_TIME entities = [ HydrawiseSwitch( @@ -74,12 +86,11 @@ def setup_platform( description=description, default_watering_timer=default_watering_timer, ) - for zone in hydrawise.relays + for zone in coordinator.api.relays for description in SWITCH_TYPES - if description.key in monitored_conditions ] - add_entities(entities, True) + async_add_entities(entities) class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 54089723e21..f229d753fec 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -206,6 +206,7 @@ FLOWS = { "huisbaasje", "hunterdouglas_powerview", "hvv_departures", + "hydrawise", "hyperion", "ialarm", "iaqualink", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index aac00cdd0d8..ef79e680ea2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2501,7 +2501,7 @@ "hydrawise": { "name": "Hunter Hydrawise", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "hyperion": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21065157095..bf22ba34a65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1244,6 +1244,9 @@ pydexcom==0.2.3 # homeassistant.components.discovergy pydiscovergy==2.0.3 +# homeassistant.components.hydrawise +pydrawise==2023.8.0 + # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/tests/components/hydrawise/__init__.py b/tests/components/hydrawise/__init__.py new file mode 100644 index 00000000000..582d20ba2df --- /dev/null +++ b/tests/components/hydrawise/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hydrawise integration.""" diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py new file mode 100644 index 00000000000..b6e22ec7b80 --- /dev/null +++ b/tests/components/hydrawise/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the Hydrawise 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.hydrawise.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py new file mode 100644 index 00000000000..c9efbea507e --- /dev/null +++ b/tests/components/hydrawise/test_config_flow.py @@ -0,0 +1,213 @@ +"""Test the Hydrawise config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from requests.exceptions import ConnectTimeout, HTTPError + +from homeassistant import config_entries +from homeassistant.components.hydrawise.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL +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 + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_form( + mock_api: MagicMock, 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["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_key": "abc123"} + ) + mock_api.return_value.customer_id = 12345 + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Hydrawise" + assert result2["data"] == {"api_key": "abc123"} + assert len(mock_setup_entry.mock_calls) == 1 + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_form_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test we handle API errors.""" + mock_api.side_effect = HTTPError + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + data = {"api_key": "abc123"} + result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], data + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_api.side_effect = None + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_form_connect_timeout(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test we handle API errors.""" + mock_api.side_effect = ConnectTimeout + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + data = {"api_key": "abc123"} + result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], data + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "timeout_connect"} + + mock_api.side_effect = None + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_flow_import_success(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test that we can import a YAML config.""" + mock_api.return_value.status = "All good!" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Hydrawise" + assert result["data"] == { + CONF_API_KEY: "__api_key__", + } + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_hydrawise" + ) + assert issue.translation_key == "deprecated_yaml" + + +@patch("pydrawise.legacy.LegacyHydrawise", side_effect=HTTPError) +async def test_flow_import_api_error(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test that we handle API errors on YAML import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) + assert issue.translation_key == "deprecated_yaml_import_issue" + + +@patch("pydrawise.legacy.LegacyHydrawise", side_effect=ConnectTimeout) +async def test_flow_import_connect_timeout( + mock_api: MagicMock, hass: HomeAssistant +) -> None: + """Test that we handle connection timeouts on YAML import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "timeout_connect" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_timeout_connect" + ) + assert issue.translation_key == "deprecated_yaml_import_issue" + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_flow_import_no_status(mock_api: MagicMock, hass: HomeAssistant) -> None: + """Test we handle a lack of API status on YAML import.""" + mock_api.return_value.status = None + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_unknown" + ) + assert issue.translation_key == "deprecated_yaml_import_issue" + + +@patch("pydrawise.legacy.LegacyHydrawise") +async def test_flow_import_already_imported( + mock_api: MagicMock, hass: HomeAssistant +) -> None: + """Test that we can handle a YAML config already imported.""" + mock_config_entry = MockConfigEntry( + title="Hydrawise", + domain=DOMAIN, + data={ + CONF_API_KEY: "__api_key__", + }, + unique_id="hydrawise-CUSTOMER_ID", + ) + mock_config_entry.add_to_hass(hass) + + mock_api.return_value.customer_id = "CUSTOMER_ID" + mock_api.return_value.status = "All good!" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "__api_key__", + CONF_SCAN_INTERVAL: 120, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_hydrawise" + ) + assert issue.translation_key == "deprecated_yaml" From 451c085587ac6f0b9d239861a58de66315f0e1aa Mon Sep 17 00:00:00 2001 From: Nathan Tilley Date: Sat, 23 Sep 2023 15:06:49 -0700 Subject: [PATCH 731/984] Bump faadelays to 2023.8.0 (#100700) * Update component to use new API version * Revert new features, implement #95546, bump library * Revert #95546 changes, remove NOTAM --- .../components/faa_delays/binary_sensor.py | 5 ++-- .../components/faa_delays/config_flow.py | 9 ++----- .../components/faa_delays/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/faa_delays/test_config_flow.py | 25 ++----------------- 6 files changed, 9 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index bc09a604cd6..54b22812c84 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -42,7 +42,7 @@ class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): self.coordinator = coordinator self._entry_id = entry_id self._attrs: dict[str, Any] = {} - _id = coordinator.data.iata + _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" @@ -83,7 +83,6 @@ class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): 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.begin + self._attrs["begin"] = self.coordinator.data.closure.start self._attrs["end"] = self.coordinator.data.closure.end - self._attrs["reason"] = self.coordinator.data.closure.reason return self._attrs diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index 023fe4d6a5b..b2f7f69dd49 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -35,10 +35,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await data.update() - except faadelays.InvalidAirport: - _LOGGER.error("Airport code %s is invalid", user_input[CONF_ID]) - errors[CONF_ID] = "invalid_airport" - except ClientConnectionError: _LOGGER.error("Error connecting to FAA API") errors["base"] = "cannot_connect" @@ -49,11 +45,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not errors: _LOGGER.debug( - "Creating entry with id: %s, name: %s", + "Creating entry with id: %s", user_input[CONF_ID], - data.name, ) - return self.async_create_entry(title=data.name, data=user_input) + return self.async_create_entry(title=data.code, data=user_input) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/faa_delays/manifest.json b/homeassistant/components/faa_delays/manifest.json index 8fb07d1e187..07c2cfea771 100644 --- a/homeassistant/components/faa_delays/manifest.json +++ b/homeassistant/components/faa_delays/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/faa_delays", "iot_class": "cloud_polling", "loggers": ["faadelays"], - "requirements": ["faadelays==0.0.7"] + "requirements": ["faadelays==2023.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e038c435aca..13ad421df96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,7 +777,7 @@ eufylife-ble-client==0.1.7 evohome-async==0.3.15 # homeassistant.components.faa_delays -faadelays==0.0.7 +faadelays==2023.9.1 # homeassistant.components.dlib_face_detect # homeassistant.components.dlib_face_identify diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf22ba34a65..6f1e3adc781 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -621,7 +621,7 @@ esphome-dashboard-api==1.2.3 eufylife-ble-client==0.1.7 # homeassistant.components.faa_delays -faadelays==0.0.7 +faadelays==2023.9.1 # homeassistant.components.feedreader feedparser==6.0.10 diff --git a/tests/components/faa_delays/test_config_flow.py b/tests/components/faa_delays/test_config_flow.py index 9eb166d5f69..5fb1b9cfcd2 100644 --- a/tests/components/faa_delays/test_config_flow.py +++ b/tests/components/faa_delays/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def mock_valid_airport(self, *args, **kwargs): """Return a valid airport.""" - self.name = "Test airport" + self.code = "test" async def test_form(hass: HomeAssistant) -> None: @@ -40,7 +40,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "Test airport" + assert result2["title"] == "test" assert result2["data"] == { "id": "test", } @@ -61,27 +61,6 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_invalid_airport(hass: HomeAssistant) -> None: - """Test we handle invalid airport.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "faadelays.Airport.update", - side_effect=faadelays.InvalidAirport, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "id": "test", - }, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {CONF_ID: "invalid_airport"} - - async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle a connection error.""" result = await hass.config_entries.flow.async_init( From ae29ddee74e51ca7406ea0d5fb8c6696046038ac Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 23 Sep 2023 15:38:53 -0700 Subject: [PATCH 732/984] Add more test coverage for fitbit sensors (#100776) --- tests/components/fitbit/conftest.py | 18 +- .../fitbit/snapshots/test_sensor.ambr | 280 ++++++++++++++++++ tests/components/fitbit/test_sensor.py | 252 +++++++++++++++- 3 files changed, 530 insertions(+), 20 deletions(-) create mode 100644 tests/components/fitbit/snapshots/test_sensor.ambr diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index e3e5bbd1d18..291951a745a 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -104,13 +104,19 @@ async def mock_sensor_platform_setup( @pytest.fixture(name="profile_id") -async def mock_profile_id() -> str: +def mock_profile_id() -> str: """Fixture for the profile id returned from the API response.""" return PROFILE_USER_ID +@pytest.fixture(name="profile_locale") +def mock_profile_locale() -> str: + """Fixture to set the API response for the user profile.""" + return "en_US" + + @pytest.fixture(name="profile", autouse=True) -async def mock_profile(requests_mock: Mocker, profile_id: str) -> None: +def mock_profile(requests_mock: Mocker, profile_id: str, profile_locale: str) -> None: """Fixture to setup fake requests made to Fitbit API during config flow.""" requests_mock.register_uri( "GET", @@ -120,20 +126,20 @@ async def mock_profile(requests_mock: Mocker, profile_id: str) -> None: "user": { "encodedId": profile_id, "fullName": "My name", - "locale": "en_US", + "locale": profile_locale, }, }, ) @pytest.fixture(name="devices_response") -async def mock_device_response() -> list[dict[str, Any]]: +def mock_device_response() -> list[dict[str, Any]]: """Return the list of devices.""" return [] @pytest.fixture(autouse=True) -async def mock_devices(requests_mock: Mocker, devices_response: dict[str, Any]) -> None: +def mock_devices(requests_mock: Mocker, devices_response: dict[str, Any]) -> None: """Fixture to setup fake device responses.""" requests_mock.register_uri( "GET", @@ -151,7 +157,7 @@ def timeseries_response(resource: str, value: str) -> dict[str, Any]: @pytest.fixture(name="register_timeseries") -async def mock_register_timeseries( +def mock_register_timeseries( requests_mock: Mocker, ) -> Callable[[str, dict[str, Any]], None]: """Fixture to setup fake timeseries API responses.""" diff --git a/tests/components/fitbit/snapshots/test_sensor.ambr b/tests/components/fitbit/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..719a2f8a6b8 --- /dev/null +++ b/tests/components/fitbit/snapshots/test_sensor.ambr @@ -0,0 +1,280 @@ +# serializer version: 1 +# name: test_sensors[monitored_resources0-sensor.activity_calories-activities/activityCalories-135] + tuple( + '135', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Activity Calories', + 'icon': 'mdi:fire', + 'unit_of_measurement': 'cal', + }), + 'fitbit-api-user-id-1_activities/activityCalories', + ) +# --- +# name: test_sensors[monitored_resources1-sensor.calories-activities/calories-139] + tuple( + '139', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Calories', + 'icon': 'mdi:fire', + 'unit_of_measurement': 'cal', + }), + 'fitbit-api-user-id-1_activities/calories', + ) +# --- +# name: test_sensors[monitored_resources10-sensor.steps-activities/steps-5600] + tuple( + '5600', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Steps', + 'icon': 'mdi:walk', + 'unit_of_measurement': 'steps', + }), + 'fitbit-api-user-id-1_activities/steps', + ) +# --- +# name: test_sensors[monitored_resources11-sensor.weight-body/weight-175] + tuple( + '175.0', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'weight', + 'friendly_name': 'Weight', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_body/weight', + ) +# --- +# name: test_sensors[monitored_resources12-sensor.body_fat-body/fat-18] + tuple( + '18.0', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Body Fat', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'fitbit-api-user-id-1_body/fat', + ) +# --- +# name: test_sensors[monitored_resources13-sensor.bmi-body/bmi-23.7] + tuple( + '23.7', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'BMI', + 'icon': 'mdi:human', + 'state_class': , + 'unit_of_measurement': 'BMI', + }), + 'fitbit-api-user-id-1_body/bmi', + ) +# --- +# name: test_sensors[monitored_resources14-sensor.awakenings_count-sleep/awakeningsCount-7] + tuple( + '7', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Awakenings Count', + 'icon': 'mdi:sleep', + 'unit_of_measurement': 'times awaken', + }), + 'fitbit-api-user-id-1_sleep/awakeningsCount', + ) +# --- +# name: test_sensors[monitored_resources15-sensor.sleep_efficiency-sleep/efficiency-80] + tuple( + '80', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Sleep Efficiency', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'fitbit-api-user-id-1_sleep/efficiency', + ) +# --- +# name: test_sensors[monitored_resources16-sensor.minutes_after_wakeup-sleep/minutesAfterWakeup-17] + tuple( + '17', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes After Wakeup', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAfterWakeup', + ) +# --- +# name: test_sensors[monitored_resources17-sensor.sleep_minutes_asleep-sleep/minutesAsleep-360] + tuple( + '360', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes Asleep', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAsleep', + ) +# --- +# name: test_sensors[monitored_resources18-sensor.sleep_minutes_awake-sleep/minutesAwake-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes Awake', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesAwake', + ) +# --- +# name: test_sensors[monitored_resources19-sensor.sleep_minutes_to_fall_asleep-sleep/minutesToFallAsleep-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Minutes to Fall Asleep', + 'icon': 'mdi:sleep', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/minutesToFallAsleep', + ) +# --- +# name: test_sensors[monitored_resources2-sensor.distance-activities/distance-12.7] + tuple( + '12.70', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'distance', + 'friendly_name': 'Distance', + 'icon': 'mdi:map-marker', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/distance', + ) +# --- +# name: test_sensors[monitored_resources20-sensor.sleep_start_time-sleep/startTime-2020-01-27T00:17:30.000] + tuple( + '2020-01-27T00:17:30.000', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Sleep Start Time', + 'icon': 'mdi:clock', + }), + 'fitbit-api-user-id-1_sleep/startTime', + ) +# --- +# name: test_sensors[monitored_resources21-sensor.sleep_time_in_bed-sleep/timeInBed-462] + tuple( + '462', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Sleep Time in Bed', + 'icon': 'mdi:hotel', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_sleep/timeInBed', + ) +# --- +# name: test_sensors[monitored_resources3-sensor.elevation-activities/elevation-7600.24] + tuple( + '7600.24', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'distance', + 'friendly_name': 'Elevation', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/elevation', + ) +# --- +# name: test_sensors[monitored_resources4-sensor.floors-activities/floors-8] + tuple( + '8', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Floors', + 'icon': 'mdi:walk', + 'unit_of_measurement': 'floors', + }), + 'fitbit-api-user-id-1_activities/floors', + ) +# --- +# name: test_sensors[monitored_resources5-sensor.resting_heart_rate-activities/heart-api_value5] + tuple( + '76', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Resting Heart Rate', + 'icon': 'mdi:heart-pulse', + 'unit_of_measurement': 'bpm', + }), + 'fitbit-api-user-id-1_activities/heart', + ) +# --- +# name: test_sensors[monitored_resources6-sensor.minutes_fairly_active-activities/minutesFairlyActive-35] + tuple( + '35', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Fairly Active', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesFairlyActive', + ) +# --- +# name: test_sensors[monitored_resources7-sensor.minutes_lightly_active-activities/minutesLightlyActive-95] + tuple( + '95', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Lightly Active', + 'icon': 'mdi:walk', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesLightlyActive', + ) +# --- +# name: test_sensors[monitored_resources8-sensor.minutes_sedentary-activities/minutesSedentary-18] + tuple( + '18', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Sedentary', + 'icon': 'mdi:seat-recline-normal', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesSedentary', + ) +# --- +# name: test_sensors[monitored_resources9-sensor.minutes_very_active-activities/minutesVeryActive-20] + tuple( + '20', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'device_class': 'duration', + 'friendly_name': 'Minutes Very Active', + 'icon': 'mdi:run', + 'unit_of_measurement': , + }), + 'fitbit-api-user-id-1_activities/minutesVeryActive', + ) +# --- diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 6918a712f72..7351f919380 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -5,10 +5,12 @@ from collections.abc import Awaitable, Callable from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import timeseries_response +from .conftest import PROFILE_USER_ID, timeseries_response DEVICE_RESPONSE_CHARGE_2 = { "battery": "Medium", @@ -31,30 +33,169 @@ DEVICE_RESPONSE_ARIA_AIR = { @pytest.mark.parametrize( - "monitored_resources", - [["activities/steps"]], + ( + "monitored_resources", + "entity_id", + "api_resource", + "api_value", + ), + [ + ( + ["activities/activityCalories"], + "sensor.activity_calories", + "activities/activityCalories", + "135", + ), + ( + ["activities/calories"], + "sensor.calories", + "activities/calories", + "139", + ), + ( + ["activities/distance"], + "sensor.distance", + "activities/distance", + "12.7", + ), + ( + ["activities/elevation"], + "sensor.elevation", + "activities/elevation", + "7600.24", + ), + ( + ["activities/floors"], + "sensor.floors", + "activities/floors", + "8", + ), + ( + ["activities/heart"], + "sensor.resting_heart_rate", + "activities/heart", + {"restingHeartRate": 76}, + ), + ( + ["activities/minutesFairlyActive"], + "sensor.minutes_fairly_active", + "activities/minutesFairlyActive", + 35, + ), + ( + ["activities/minutesLightlyActive"], + "sensor.minutes_lightly_active", + "activities/minutesLightlyActive", + 95, + ), + ( + ["activities/minutesSedentary"], + "sensor.minutes_sedentary", + "activities/minutesSedentary", + 18, + ), + ( + ["activities/minutesVeryActive"], + "sensor.minutes_very_active", + "activities/minutesVeryActive", + 20, + ), + ( + ["activities/steps"], + "sensor.steps", + "activities/steps", + "5600", + ), + ( + ["body/weight"], + "sensor.weight", + "body/weight", + "175", + ), + ( + ["body/fat"], + "sensor.body_fat", + "body/fat", + "18", + ), + ( + ["body/bmi"], + "sensor.bmi", + "body/bmi", + "23.7", + ), + ( + ["sleep/awakeningsCount"], + "sensor.awakenings_count", + "sleep/awakeningsCount", + "7", + ), + ( + ["sleep/efficiency"], + "sensor.sleep_efficiency", + "sleep/efficiency", + "80", + ), + ( + ["sleep/minutesAfterWakeup"], + "sensor.minutes_after_wakeup", + "sleep/minutesAfterWakeup", + "17", + ), + ( + ["sleep/minutesAsleep"], + "sensor.sleep_minutes_asleep", + "sleep/minutesAsleep", + "360", + ), + ( + ["sleep/minutesAwake"], + "sensor.sleep_minutes_awake", + "sleep/minutesAwake", + "35", + ), + ( + ["sleep/minutesToFallAsleep"], + "sensor.sleep_minutes_to_fall_asleep", + "sleep/minutesToFallAsleep", + "35", + ), + ( + ["sleep/startTime"], + "sensor.sleep_start_time", + "sleep/startTime", + "2020-01-27T00:17:30.000", + ), + ( + ["sleep/timeInBed"], + "sensor.sleep_time_in_bed", + "sleep/timeInBed", + "462", + ), + ], ) -async def test_step_sensor( +async def test_sensors( hass: HomeAssistant, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, + entity_id: str, + api_resource: str, + api_value: str, + snapshot: SnapshotAssertion, ) -> None: - """Test battery level sensor.""" + """Test sensors.""" register_timeseries( - "activities/steps", timeseries_response("activities-steps", "5600") + api_resource, timeseries_response(api_resource.replace("/", "-"), api_value) ) await sensor_platform_setup() - state = hass.states.get("sensor.steps") + state = hass.states.get(entity_id) assert state - assert state.state == "5600" - assert state.attributes == { - "attribution": "Data provided by Fitbit.com", - "friendly_name": "Steps", - "icon": "mdi:walk", - "unit_of_measurement": "steps", - } + entry = entity_registry.async_get(entity_id) + assert entry + assert (state.state, state.attributes, entry.unique_id) == snapshot @pytest.mark.parametrize( @@ -64,6 +205,7 @@ async def test_step_sensor( async def test_device_battery_level( hass: HomeAssistant, sensor_platform_setup: Callable[[], Awaitable[bool]], + entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" @@ -80,6 +222,10 @@ async def test_device_battery_level( "type": "tracker", } + entry = entity_registry.async_get("sensor.charge_2_battery") + assert entry + assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_816713257" + state = hass.states.get("sensor.aria_air_battery") assert state assert state.state == "High" @@ -90,3 +236,81 @@ async def test_device_battery_level( "model": "Aria Air", "type": "scale", } + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("sensor.aria_air_battery") + assert entry + assert entry.unique_id == f"{PROFILE_USER_ID}_devices/battery_016713257" + + +@pytest.mark.parametrize( + ("monitored_resources", "profile_locale", "expected_unit"), + [ + (["body/weight"], "en_US", "kg"), + (["body/weight"], "en_GB", "st"), + (["body/weight"], "es_ES", "kg"), + ], +) +async def test_profile_local( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + expected_unit: str, +) -> None: + """Test the fitbit profile locale impact on unit of measure.""" + + register_timeseries("body/weight", timeseries_response("body-weight", "175")) + await sensor_platform_setup() + + state = hass.states.get("sensor.weight") + assert state + assert state.attributes.get("unit_of_measurement") == expected_unit + + +@pytest.mark.parametrize( + ("sensor_platform_config", "api_response", "expected_state"), + [ + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "17:05", + "5:05 PM", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "5:05", + "5:05 AM", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "00:05", + "12:05 AM", + ), + ( + {"clock_format": "24H", "monitored_resources": ["sleep/startTime"]}, + "17:05", + "17:05", + ), + ( + {"clock_format": "12H", "monitored_resources": ["sleep/startTime"]}, + "", + "-", + ), + ], +) +async def test_sleep_time_clock_format( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + api_response: str, + expected_state: str, +) -> None: + """Test the clock format configuration.""" + + register_timeseries( + "sleep/startTime", timeseries_response("sleep-startTime", api_response) + ) + await sensor_platform_setup() + + state = hass.states.get("sensor.sleep_start_time") + assert state + assert state.state == expected_state From d453f3809c5302245523519e49a6ee158270d931 Mon Sep 17 00:00:00 2001 From: Nathan Tilley Date: Sat, 23 Sep 2023 22:02:34 -0700 Subject: [PATCH 733/984] Clean up FAA Delays constants (#100788) Move const to platform --- .../components/faa_delays/binary_sensor.py | 30 ++++++++++++++++++- homeassistant/components/faa_delays/const.py | 30 ------------------- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 54b22812c84..5cbb206f223 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -12,7 +12,35 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, FAA_BINARY_SENSORS +from .const import DOMAIN + +FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="GROUND_DELAY", + name="Ground Delay", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="GROUND_STOP", + name="Ground Stop", + icon="mdi:airport", + ), + BinarySensorEntityDescription( + key="DEPART_DELAY", + name="Departure Delay", + icon="mdi:airplane-takeoff", + ), + BinarySensorEntityDescription( + key="ARRIVE_DELAY", + name="Arrival Delay", + icon="mdi:airplane-landing", + ), + BinarySensorEntityDescription( + key="CLOSURE", + name="Closure", + icon="mdi:airplane:off", + ), +) async def async_setup_entry( diff --git a/homeassistant/components/faa_delays/const.py b/homeassistant/components/faa_delays/const.py index f7ee8e7bad8..3b9bda33bfb 100644 --- a/homeassistant/components/faa_delays/const.py +++ b/homeassistant/components/faa_delays/const.py @@ -1,34 +1,4 @@ """Constants for the FAA Delays integration.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntityDescription - DOMAIN = "faa_delays" - -FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( - key="GROUND_DELAY", - name="Ground Delay", - icon="mdi:airport", - ), - BinarySensorEntityDescription( - key="GROUND_STOP", - name="Ground Stop", - icon="mdi:airport", - ), - BinarySensorEntityDescription( - key="DEPART_DELAY", - name="Departure Delay", - icon="mdi:airplane-takeoff", - ), - BinarySensorEntityDescription( - key="ARRIVE_DELAY", - name="Arrival Delay", - icon="mdi:airplane-landing", - ), - BinarySensorEntityDescription( - key="CLOSURE", - name="Closure", - icon="mdi:airplane:off", - ), -) From eb020dd66c894a6c3e8b2c456653eddfb0ec0da5 Mon Sep 17 00:00:00 2001 From: AtomBrake <45041222+AtomBrake@users.noreply.github.com> Date: Sun, 24 Sep 2023 07:02:48 +0100 Subject: [PATCH 734/984] Update powerwall password description (#100389) Update strings.json Updated wording of how to find password on newer model gateways --- homeassistant/components/powerwall/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index dacf63a68dd..8be76dc8716 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Connect to the Powerwall", - "description": "The default password is printed inside the Backup Gateway for newer models. For older models, the default password is the last five characters of the serial number for Backup Gateway and can be found in the Tesla app.", + "description": "The default password is the last 5 characters of the password printed inside the Backup Gateway for newer models. For older models, the default password is the last five characters of the serial number for Backup Gateway and can be found in the Tesla app.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "password": "[%key:common::config_flow::data::password%]" From f0375eb97e721e6850b723c1dc0c8735c211f3a1 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sun, 24 Sep 2023 09:45:25 +0100 Subject: [PATCH 735/984] Expose bluetooth availability tracking interval controls to integrations (#100774) --- .../components/bluetooth/__init__.py | 6 + homeassistant/components/bluetooth/api.py | 24 +++ homeassistant/components/bluetooth/manager.py | 29 +++- .../bluetooth/test_advertisement_tracker.py | 41 +++++ tests/components/bluetooth/test_manager.py | 147 +++++++++++++++++- 5 files changed, 244 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 2e0e62440ab..c59249e8bd5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -45,6 +45,8 @@ from .api import ( async_ble_device_from_address, async_discovered_service_info, async_get_advertisement_callback, + async_get_fallback_availability_interval, + async_get_learned_advertising_interval, async_get_scanner, async_last_service_info, async_process_advertisements, @@ -54,6 +56,7 @@ from .api import ( async_scanner_by_source, async_scanner_count, async_scanner_devices_by_address, + async_set_fallback_availability_interval, async_track_unavailable, ) from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice @@ -86,12 +89,15 @@ __all__ = [ "async_address_present", "async_ble_device_from_address", "async_discovered_service_info", + "async_get_fallback_availability_interval", + "async_get_learned_advertising_interval", "async_get_scanner", "async_last_service_info", "async_process_advertisements", "async_rediscover_address", "async_register_callback", "async_register_scanner", + "async_set_fallback_availability_interval", "async_track_unavailable", "async_scanner_by_source", "async_scanner_count", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index e364fd08e88..9d24428e3d2 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -197,3 +197,27 @@ def async_get_advertisement_callback( ) -> Callable[[BluetoothServiceInfoBleak], None]: """Get the advertisement callback.""" return _get_manager(hass).scanner_adv_received + + +@hass_callback +def async_get_learned_advertising_interval( + hass: HomeAssistant, address: str +) -> float | None: + """Get the learned advertising interval for a MAC address.""" + return _get_manager(hass).async_get_learned_advertising_interval(address) + + +@hass_callback +def async_get_fallback_availability_interval( + hass: HomeAssistant, address: str +) -> float | None: + """Get the fallback availability timeout for a MAC address.""" + return _get_manager(hass).async_get_fallback_availability_interval(address) + + +@hass_callback +def async_set_fallback_availability_interval( + hass: HomeAssistant, address: str, interval: float +) -> None: + """Override the fallback availability timeout for a MAC address.""" + _get_manager(hass).async_set_fallback_availability_interval(address, interval) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index bd91c622316..80fbe2d49a5 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -108,6 +108,7 @@ class BluetoothManager: "_cancel_unavailable_tracking", "_cancel_logging_listener", "_advertisement_tracker", + "_fallback_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", "_callback_index", @@ -139,6 +140,7 @@ class BluetoothManager: self._cancel_logging_listener: CALLBACK_TYPE | None = None self._advertisement_tracker = AdvertisementTracker() + self._fallback_intervals: dict[str, float] = {} self._unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] @@ -342,7 +344,9 @@ class BluetoothManager: # since it may have gone to sleep and since we do not need an active # connection to it we can only determine its availability # by the lack of advertisements - if advertising_interval := intervals.get(address): + if advertising_interval := ( + intervals.get(address) or self._fallback_intervals.get(address) + ): advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS else: advertising_interval = ( @@ -355,6 +359,7 @@ class BluetoothManager: # The second loop (connectable=False) is responsible for removing # the device from all the interval tracking since it is no longer # available for both connectable and non-connectable + self._fallback_intervals.pop(address, None) tracker.async_remove_address(address) self._integration_matcher.async_clear_address(address) self._async_dismiss_discoveries(address) @@ -386,7 +391,10 @@ class BluetoothManager: """Prefer previous advertisement from a different source if it is better.""" if new.time - old.time > ( stale_seconds := self._advertisement_tracker.intervals.get( - new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + new.address, + self._fallback_intervals.get( + new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + ), ) ): # If the old advertisement is stale, any new advertisement is preferred @@ -779,3 +787,20 @@ class BluetoothManager: 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._advertisement_tracker.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 diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 5a2c55259bb..f04ea2873f0 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components.bluetooth import ( + async_get_learned_advertising_interval, async_register_scanner, async_track_unavailable, ) @@ -62,6 +63,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout( SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:12" + ) == pytest.approx(2.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -109,6 +114,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectab SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:18" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -158,6 +167,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c "original", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(2.0) + for i in range(ADVERTISING_TIMES_NEEDED): inject_advertisement_with_time_and_source( hass, @@ -167,6 +180,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, switchbot_device.address ) @@ -216,6 +233,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_conne SOURCE_LOCAL, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -270,6 +291,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ "original", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:5C" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_adv_better_rssi = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -284,6 +309,10 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:5C" + ) == pytest.approx(2.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -342,6 +371,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c connectable=False, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(2.0) + switchbot_better_rssi_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -357,6 +390,10 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c connectable=False, ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(ONE_HOUR_SECONDS) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, @@ -437,6 +474,10 @@ async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeou "new", ) + assert async_get_learned_advertising_interval( + hass, "44:44:33:11:23:45" + ) == pytest.approx(61.0) + switchbot_device_unavailable_cancel = async_track_unavailable( hass, _switchbot_device_unavailable_callback, diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index f637ee3a27a..63091b18843 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -20,11 +20,17 @@ from homeassistant.components.bluetooth import ( HaBluetoothConnector, async_ble_device_from_address, async_get_advertisement_callback, + async_get_fallback_availability_interval, + async_get_learned_advertising_interval, async_scanner_count, + async_set_fallback_availability_interval, async_track_unavailable, storage, ) -from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS +from homeassistant.components.bluetooth.const import ( + SOURCE_LOCAL, + UNAVAILABLE_TRACK_SECONDS, +) from homeassistant.components.bluetooth.manager import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) @@ -1053,3 +1059,142 @@ async def test_debug_logging( "hci0", ) assert "wohand_good_signal_hci0" not in caplog.text + + +async def test_set_fallback_interval_small( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_bluetooth: None, + macos_adapter: None, +) -> None: + """Test we can set the fallback advertisement interval.""" + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + async_set_fallback_availability_interval(hass, "44:44:33:11:23:12", 2.0) + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") == 2.0 + + start_monotonic_time = time.monotonic() + switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time, + SOURCE_LOCAL, + ) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + assert async_get_learned_advertising_interval(hass, "44:44:33:11:23:12") is None + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + monotonic_now = start_monotonic_time + 2 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + switchbot_device_unavailable_cancel() + + # We should forget fallback interval after it expires + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + +async def test_set_fallback_interval_big( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_bluetooth: None, + macos_adapter: None, +) -> None: + """Test we can set the fallback advertisement interval.""" + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + # Force the interval to be really big and check it doesn't expire using the default timeout (900) + + async_set_fallback_availability_interval(hass, "44:44:33:11:23:12", 604800.0) + assert ( + async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") == 604800.0 + ) + + start_monotonic_time = time.monotonic() + switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + switchbot_device_went_unavailable = False + + inject_advertisement_with_time_and_source( + hass, + switchbot_device, + switchbot_adv, + start_monotonic_time, + SOURCE_LOCAL, + ) + + @callback + def _switchbot_device_unavailable_callback(_address: str) -> None: + """Switchbot device unavailable callback.""" + nonlocal switchbot_device_went_unavailable + switchbot_device_went_unavailable = True + + assert async_get_learned_advertising_interval(hass, "44:44:33:11:23:12") is None + + switchbot_device_unavailable_cancel = async_track_unavailable( + hass, + _switchbot_device_unavailable_callback, + switchbot_device.address, + connectable=False, + ) + + # Check that device hasn't expired after a day + + monotonic_now = start_monotonic_time + 86400 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is False + + # Try again after it has expired + + monotonic_now = start_monotonic_time + 604800 + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) + ) + await hass.async_block_till_done() + + assert switchbot_device_went_unavailable is True + + switchbot_device_unavailable_cancel() + + # We should forget fallback interval after it expires + assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None From edb28be964d9cff29b1a82660873ab20cc499040 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 24 Sep 2023 12:52:13 +0200 Subject: [PATCH 736/984] Avoid redundant calls to async_write_ha_state in mqtt device_tracker (#100767) Avoid redundant calls to async_ha_write_state --- .../components/mqtt/device_tracker.py | 6 +-- tests/components/mqtt/test_device_tracker.py | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 67355d9bca5..f99eab4d58f 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -36,9 +36,10 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType -from .util import get_mqtt_data, valid_subscribe_topic +from .util import valid_subscribe_topic CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" @@ -135,6 +136,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_location_name"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload: ReceivePayloadType = self._value_template(msg.payload) @@ -148,8 +150,6 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): assert isinstance(msg.payload, str) self._location_name = msg.payload - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - state_topic: str | None = self._config.get(CONF_STATE_TOPIC) if state_topic is None: return diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 8485e5578fe..204b149e479 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -13,8 +13,10 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_reloadable, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, ) from tests.common import async_fire_mqtt_message @@ -636,3 +638,38 @@ async def test_reloadable( domain = device_tracker.DOMAIN config = DEFAULT_CONFIG await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + device_tracker.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "home", "work"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 1b1901cb6d0c3b5706e6622c52925c86e1814e1d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 24 Sep 2023 14:36:02 +0200 Subject: [PATCH 737/984] Update home-assistant/builder to 2023.09.0 (#100797) --- .github/workflows/builder.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0b0983a001f..191b510c0ff 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.08.0 + uses: home-assistant/builder@2023.09.0 with: args: | $BUILD_ARGS \ @@ -205,8 +205,6 @@ jobs: --cosign \ --target /data \ --generic ${{ needs.init.outputs.version }} - env: - CAS_API_KEY: ${{ secrets.CAS_TOKEN }} - name: Archive translations shell: bash @@ -275,15 +273,13 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.08.0 + uses: home-assistant/builder@2023.09.0 with: args: | $BUILD_ARGS \ --target /data/machine \ --cosign \ --machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}" - env: - CAS_API_KEY: ${{ secrets.CAS_TOKEN }} publish_ha: name: Publish version files From 0dc21504f5ba441bbe32c2e8b6c2868dbf09dc6a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 24 Sep 2023 14:45:06 +0200 Subject: [PATCH 738/984] Remove support for excluding attributes in recorder platforms (#100679) --- homeassistant/components/recorder/__init__.py | 22 ++----------------- homeassistant/components/recorder/const.py | 7 ------ homeassistant/components/recorder/core.py | 5 +---- .../components/recorder/db_schema.py | 10 +-------- .../table_managers/state_attributes.py | 6 +---- tests/components/recorder/test_init.py | 21 ++++++++++-------- tests/components/recorder/test_models.py | 2 +- 7 files changed, 18 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 72d825d9e78..1c00149192f 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -27,9 +27,7 @@ from .const import ( # noqa: F401 DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, - EXCLUDE_ATTRIBUTES, INTEGRATION_PLATFORM_COMPILE_STATISTICS, - INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD, SQLITE_URL_PREFIX, SupportedDialect, @@ -132,8 +130,6 @@ def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" - exclude_attributes_by_domain: dict[str, set[str]] = {} - hass.data[EXCLUDE_ATTRIBUTES] = exclude_attributes_by_domain conf = config[DOMAIN] entity_filter = convert_include_exclude_filter(conf).get_filter() auto_purge = conf[CONF_AUTO_PURGE] @@ -161,7 +157,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: db_retry_wait=db_retry_wait, entity_filter=entity_filter, exclude_event_types=exclude_event_types, - exclude_attributes_by_domain=exclude_attributes_by_domain, ) instance.async_initialize() instance.async_register() @@ -170,17 +165,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_setup(hass) entity_registry.async_setup(hass) - await _async_setup_integration_platform( - hass, instance, exclude_attributes_by_domain - ) + await _async_setup_integration_platform(hass, instance) return await instance.async_db_ready async def _async_setup_integration_platform( - hass: HomeAssistant, - instance: Recorder, - exclude_attributes_by_domain: dict[str, set[str]], + hass: HomeAssistant, instance: Recorder ) -> None: """Set up a recorder integration platform.""" @@ -188,15 +179,6 @@ async def _async_setup_integration_platform( hass: HomeAssistant, domain: str, platform: Any ) -> None: """Process a recorder platform.""" - # We need to add this before as soon as the component is loaded - # to ensure by the time the state is recorded that the excluded - # attributes are known. This is safe to modify in the event loop - # since exclude_attributes_by_domain is never iterated over. - if exclude_attributes := getattr( - platform, INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, None - ): - exclude_attributes_by_domain[domain] = exclude_attributes(hass) - # If the platform has a compile_statistics method, we need to # add it to the recorder queue to be processed. if any( diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 724a9589680..7389cbf8ddf 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -40,10 +40,6 @@ ATTR_APPLY_FILTER = "apply_filter" KEEPALIVE_TIME = 30 - -EXCLUDE_ATTRIBUTES = f"{DOMAIN}_exclude_attributes_by_domain" - - STATISTICS_ROWS_SCHEMA_VERSION = 23 CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 EVENT_TYPE_IDS_SCHEMA_VERSION = 37 @@ -51,9 +47,6 @@ STATES_META_SCHEMA_VERSION = 38 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 - -INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES = "exclude_attributes" - INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics" INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8aa2bce96b1..0e926ad2a22 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -177,7 +177,6 @@ class Recorder(threading.Thread): db_retry_wait: int, entity_filter: Callable[[str], bool], exclude_event_types: set[str], - exclude_attributes_by_domain: dict[str, set[str]], ) -> None: """Initialize the recorder.""" threading.Thread.__init__(self, name="Recorder") @@ -221,9 +220,7 @@ class Recorder(threading.Thread): self.event_data_manager = EventDataManager(self) self.event_type_manager = EventTypeManager(self) self.states_meta_manager = StatesMetaManager(self) - self.state_attributes_manager = StateAttributesManager( - self, exclude_attributes_by_domain - ) + self.state_attributes_manager = StateAttributesManager(self) self.statistics_meta_manager = StatisticsMetaManager(self) self.event_session: Session | None = None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index e992a683cb1..17e34af1e11 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -39,7 +39,7 @@ from homeassistant.const import ( MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, ) -from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.core import Context, Event, EventOrigin, State from homeassistant.helpers.entity import EntityInfo from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null import homeassistant.util.dt as dt_util @@ -560,7 +560,6 @@ class StateAttributes(Base): def shared_attrs_bytes_from_event( event: Event, entity_sources: dict[str, EntityInfo], - exclude_attrs_by_domain: dict[str, set[str]], dialect: SupportedDialect | None, ) -> bytes: """Create shared_attrs from a state_changed event.""" @@ -568,14 +567,7 @@ class StateAttributes(Base): # None state means the state was removed from the state machine if state is None: return b"{}" - domain = split_entity_id(state.entity_id)[0] exclude_attrs = set(ALL_DOMAIN_EXCLUDE_ATTRS) - if base_platform_attrs := exclude_attrs_by_domain.get(domain): - exclude_attrs |= base_platform_attrs - if (entity_info := entity_sources.get(state.entity_id)) and ( - integration_attrs := exclude_attrs_by_domain.get(entity_info["domain"]) - ): - exclude_attrs |= integration_attrs if state_info := state.state_info: exclude_attrs |= state_info["unrecorded_attributes"] encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 3ae67b932bf..653ef1689bd 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -34,13 +34,10 @@ _LOGGER = logging.getLogger(__name__) class StateAttributesManager(BaseLRUTableManager[StateAttributes]): """Manage the StateAttributes table.""" - def __init__( - self, recorder: Recorder, exclude_attributes_by_domain: dict[str, set[str]] - ) -> None: + def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) self.active = True # always active - self._exclude_attributes_by_domain = exclude_attributes_by_domain self._entity_sources = entity_sources(recorder.hass) def serialize_from_event(self, event: Event) -> bytes | None: @@ -49,7 +46,6 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): return StateAttributes.shared_attrs_bytes_from_event( event, self._entity_sources, - self._exclude_attributes_by_domain, self.recorder.dialect_name, ) except JSON_ENCODE_EXCEPTIONS as ex: diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index e4e5e49eab5..0dfbb6005c4 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -119,7 +119,6 @@ def _default_recorder(hass): db_retry_wait=3, entity_filter=CONFIG_SCHEMA({DOMAIN: {}}), exclude_event_types=set(), - exclude_attributes_by_domain={}, ) @@ -2264,17 +2263,14 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: assert connect_params[0]["charset"] == "utf8mb4" -@pytest.mark.parametrize("core_state", [CoreState.starting, CoreState.running]) async def test_excluding_attributes_by_integration( recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry, - core_state: CoreState, ) -> None: - """Test that an integration's recorder platform can exclude attributes.""" - hass.state = core_state + """Test that an entity can exclude attributes from being recorded.""" state = "restoring_from_db" - attributes = {"test_attr": 5, "excluded": 10} + attributes = {"test_attr": 5, "excluded_component": 10, "excluded_integration": 20} mock_platform( hass, "fake_integration.recorder", @@ -2284,10 +2280,17 @@ async def test_excluding_attributes_by_integration( hass.bus.async_fire(EVENT_COMPONENT_LOADED, {"component": "fake_integration"}) await hass.async_block_till_done() + class EntityWithExcludedAttributes(MockEntity): + _entity_component_unrecorded_attributes = frozenset({"excluded_component"}) + _unrecorded_attributes = frozenset({"excluded_integration"}) + entity_id = "test.fake_integration_recorder" - platform = MockEntityPlatform(hass, platform_name="fake_integration") - entity_platform = MockEntity(entity_id=entity_id, extra_state_attributes=attributes) - await platform.async_add_entities([entity_platform]) + entity_platform = MockEntityPlatform(hass, platform_name="fake_integration") + entity = EntityWithExcludedAttributes( + entity_id=entity_id, + extra_state_attributes=attributes, + ) + await entity_platform.async_add_entities([entity]) await hass.async_block_till_done() await async_wait_recording_done(hass) diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index c73a0db6c76..f5ea8ff1656 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -77,7 +77,7 @@ def test_from_event_to_db_state_attributes() -> None: dialect = SupportedDialect.MYSQL db_attrs.shared_attrs = StateAttributes.shared_attrs_bytes_from_event( - event, {}, {}, dialect + event, {}, dialect ) assert db_attrs.to_native() == attrs From b19a0fb2e929c59af43d6225a74c759cebc10820 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 24 Sep 2023 14:51:56 +0200 Subject: [PATCH 739/984] Fix Comelit device info (#100587) --- .../components/comelit/coordinator.py | 39 ++++++++++++++++++- homeassistant/components/comelit/light.py | 14 ++----- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 1fcbd7c0d37..df1d745ce8a 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -3,11 +3,14 @@ import asyncio from datetime import timedelta from typing import Any -from aiocomelit import ComeliteSerialBridgeApi +from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject +from aiocomelit.const import BRIDGE import aiohttp +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import _LOGGER, DOMAIN @@ -16,6 +19,8 @@ from .const import _LOGGER, DOMAIN class ComelitSerialBridge(DataUpdateCoordinator): """Queries Comelit Serial Bridge.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, host: str, pin: int) -> None: """Initialize the scanner.""" @@ -30,6 +35,38 @@ class ComelitSerialBridge(DataUpdateCoordinator): name=f"{DOMAIN}-{host}-coordinator", update_interval=timedelta(seconds=5), ) + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, self.config_entry.entry_id)}, + model=BRIDGE, + name=f"{BRIDGE} ({self.api.host})", + **self.basic_device_info, + ) + + @property + def basic_device_info(self) -> dict: + """Set basic device info.""" + + return { + "manufacturer": "Comelit", + "hw_version": "20003101", + } + + def platform_device_info( + self, device: ComelitSerialBridgeObject, platform: str + ) -> dr.DeviceInfo: + """Set platform device info.""" + + return dr.DeviceInfo( + identifiers={ + (DOMAIN, f"{self.config_entry.entry_id}-{platform}-{device.index}") + }, + via_device=(DOMAIN, self.config_entry.entry_id), + name=device.name, + model=f"{BRIDGE} {platform}", + **self.basic_device_info, + ) async def _async_update_data(self) -> dict[str, Any]: """Update router data.""" diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 9a893bd929c..a4a534025f0 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -9,7 +9,6 @@ from aiocomelit.const import LIGHT, LIGHT_OFF, LIGHT_ON from homeassistant.components.light import LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,27 +36,20 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): """Light device.""" _attr_has_entity_name = True - _attr_name = None def __init__( self, coordinator: ComelitSerialBridge, device: ComelitSerialBridgeObject, - config_entry_unique_id: str | None, + config_entry_unique_id: str, ) -> None: """Init light entity.""" self._api = coordinator.api self._device = device super().__init__(coordinator) + self._attr_name = device.name self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, self._attr_unique_id), - }, - manufacturer="Comelit", - model="Serial Bridge", - name=device.name, - ) + self._attr_device_info = self.coordinator.platform_device_info(device, LIGHT) async def _light_set_state(self, state: int) -> None: """Set desired light state.""" From 49715f300a536bfd55dcdb14418ec8723e0e606c Mon Sep 17 00:00:00 2001 From: Scott Colby Date: Sun, 24 Sep 2023 10:13:45 -0400 Subject: [PATCH 740/984] Allow workday sensor to be configured without a country (#93048) * Merge branch 'dev' into workday_without_country * ruff * remove province check * Remove not needed test * Mod config flow --------- Co-authored-by: G Johansson --- homeassistant/components/workday/__init__.py | 5 ++- .../components/workday/binary_sensor.py | 20 +++++---- .../components/workday/config_flow.py | 28 ++++++++----- homeassistant/components/workday/strings.json | 5 +++ tests/components/workday/__init__.py | 16 +++++++ .../components/workday/test_binary_sensor.py | 32 +++++++++++++- tests/components/workday/test_config_flow.py | 42 +++++++++++++++++++ 7 files changed, 126 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 84ed67a36dd..c3bf7f2efd5 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -13,12 +13,13 @@ from .const import CONF_COUNTRY, CONF_PROVINCE, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Workday from a config entry.""" - country: str = entry.options[CONF_COUNTRY] + country: str | None = entry.options.get(CONF_COUNTRY) province: str | None = entry.options.get(CONF_PROVINCE) + if country and country not in list_supported_countries(): raise ConfigEntryError(f"Selected country {country} is not valid") - if province and province not in list_supported_countries()[country]: + if country and province and province not in list_supported_countries()[country]: raise ConfigEntryError( f"Selected province {province} for country {country} is not valid" ) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index ad18c8863d6..b60346c3bbb 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -121,21 +121,25 @@ async def async_setup_entry( """Set up the Workday sensor.""" add_holidays: list[DateLike] = entry.options[CONF_ADD_HOLIDAYS] remove_holidays: list[str] = entry.options[CONF_REMOVE_HOLIDAYS] - country: str = entry.options[CONF_COUNTRY] + country: str | None = entry.options.get(CONF_COUNTRY) days_offset: int = int(entry.options[CONF_OFFSET]) excludes: list[str] = entry.options[CONF_EXCLUDES] province: str | None = entry.options.get(CONF_PROVINCE) sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] + year: int = (dt_util.now() + timedelta(days=days_offset)).year - cls: HolidayBase = country_holidays(country, subdiv=province, years=year) - obj_holidays: HolidayBase = country_holidays( - country, - subdiv=province, - years=year, - language=cls.default_language, - ) + if country: + cls: HolidayBase = country_holidays(country, subdiv=province, years=year) + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=year, + language=cls.default_language, + ) + else: + obj_holidays = HolidayBase() # Add custom holidays try: diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 54c6196b75b..df74fff83e1 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -52,7 +52,7 @@ def add_province_to_schema( ) -> vol.Schema: """Update schema with province from country.""" all_countries = list_supported_countries() - if not all_countries[country]: + if not all_countries.get(country): return schema province_list = [NONE_SENTINEL, *all_countries[country]] @@ -71,19 +71,21 @@ def add_province_to_schema( def validate_custom_dates(user_input: dict[str, Any]) -> None: """Validate custom dates for add/remove holidays.""" - for add_date in user_input[CONF_ADD_HOLIDAYS]: if dt_util.parse_date(add_date) is None: raise AddDatesError("Incorrect date") - cls: HolidayBase = country_holidays(user_input[CONF_COUNTRY]) year: int = dt_util.now().year - obj_holidays: HolidayBase = country_holidays( - user_input[CONF_COUNTRY], - subdiv=user_input.get(CONF_PROVINCE), - years=year, - language=cls.default_language, - ) + if country := user_input[CONF_COUNTRY]: + cls = country_holidays(country) + obj_holidays = country_holidays( + country=country, + subdiv=user_input.get(CONF_PROVINCE), + years=year, + language=cls.default_language, + ) + else: + obj_holidays = HolidayBase(years=year) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: if dt_util.parse_date(remove_date) is None: @@ -94,10 +96,11 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: DATA_SCHEMA_SETUP = vol.Schema( { vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), - vol.Required(CONF_COUNTRY): SelectSelector( + vol.Optional(CONF_COUNTRY, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( - options=list(list_supported_countries()), + options=[NONE_SENTINEL, *list(list_supported_countries())], mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_COUNTRY, ) ), } @@ -208,6 +211,9 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: combined_input: dict[str, Any] = {**self.data, **user_input} + + if combined_input.get(CONF_COUNTRY, NONE_SENTINEL) == NONE_SENTINEL: + combined_input[CONF_COUNTRY] = None if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: combined_input[CONF_PROVINCE] = None diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index a217a7a36b1..b4bad4796bc 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -66,6 +66,11 @@ } }, "selector": { + "country": { + "options": { + "none": "No country" + } + }, "province": { "options": { "none": "No subdivision" diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index f87328998e1..2a1b61a0a0f 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -40,6 +40,22 @@ async def init_integration( return config_entry +TEST_CONFIG_NO_COUNTRY = { + "name": DEFAULT_NAME, + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY = { + "name": DEFAULT_NAME, + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2020-02-24"], + "remove_holidays": [], +} TEST_CONFIG_WITH_PROVINCE = { "name": DEFAULT_NAME, "country": "DE", diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 51280c8d75c..a3923bfb291 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -19,6 +19,8 @@ from . import ( TEST_CONFIG_INCORRECT_ADD_REMOVE, TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_NO_COUNTRY, + TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY, TEST_CONFIG_NO_PROVINCE, TEST_CONFIG_NO_STATE, TEST_CONFIG_REMOVE_HOLIDAY, @@ -49,6 +51,7 @@ async def test_valid_country_yaml() -> None: @pytest.mark.parametrize( ("config", "expected_state"), [ + (TEST_CONFIG_NO_COUNTRY, "on"), (TEST_CONFIG_WITH_PROVINCE, "off"), (TEST_CONFIG_NO_PROVINCE, "off"), (TEST_CONFIG_WITH_STATE, "on"), @@ -71,6 +74,7 @@ async def test_setup( await init_integration(hass, config) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == expected_state assert state.attributes == { "friendly_name": "Workday Sensor", @@ -99,6 +103,7 @@ async def test_setup_from_import( await hass.async_block_till_done() state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "off" assert state.attributes == { "friendly_name": "Workday Sensor", @@ -110,7 +115,6 @@ async def test_setup_from_import( async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: """Test setup invalid province with import.""" - await async_setup_component( hass, "binary_sensor", @@ -137,11 +141,20 @@ async def test_setup_with_working_holiday( await init_integration(hass, TEST_CONFIG_INCLUDE_HOLIDAY) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "on" +@pytest.mark.parametrize( + "config", + [ + TEST_CONFIG_EXAMPLE_2, + TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY, + ], +) async def test_setup_add_holiday( hass: HomeAssistant, + config: dict[str, Any], freezer: FrozenDateTimeFactory, ) -> None: """Test setup from various configs.""" @@ -149,6 +162,20 @@ async def test_setup_add_holiday( await init_integration(hass, TEST_CONFIG_EXAMPLE_2) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "off" + + +async def test_setup_no_country_weekend( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup shows weekend as non-workday with no country.""" + freezer.move_to(datetime(2020, 2, 23, 12, tzinfo=UTC)) # Sunday + await init_integration(hass, TEST_CONFIG_NO_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "off" @@ -161,6 +188,7 @@ async def test_setup_remove_holiday( await init_integration(hass, TEST_CONFIG_REMOVE_HOLIDAY) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "on" @@ -173,6 +201,7 @@ async def test_setup_remove_holiday_named( await init_integration(hass, TEST_CONFIG_REMOVE_NAMED) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "on" @@ -185,6 +214,7 @@ async def test_setup_day_after_tomorrow( await init_integration(hass, TEST_CONFIG_DAY_AFTER_TOMORROW) state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None assert state.state == "off" diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 7e28471c78c..78cbbf97fed 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -72,6 +72,48 @@ async def test_form(hass: HomeAssistant) -> None: } +async def test_form_no_country(hass: HomeAssistant) -> None: + """Test we get the forms correctly without a country.""" + + 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_NAME: "Workday Sensor", + CONF_COUNTRY: "none", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": None, + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + } + + async def test_form_no_subdivision(hass: HomeAssistant) -> None: """Test we get the forms correctly without subdivision.""" From 0c89f5953f32bb4f9f90a24f41f7d3f38315134b Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sun, 24 Sep 2023 19:24:12 +0100 Subject: [PATCH 741/984] Preserve private ble device broadcast interval when MAC address rotates (#100796) --- .../private_ble_device/coordinator.py | 10 +++ .../components/private_ble_device/sensor.py | 29 +++++++-- .../private_ble_device/strings.json | 3 + .../private_ble_device/test_device_tracker.py | 23 +++++++ .../private_ble_device/test_sensor.py | 63 ++++++++++++++++++- 5 files changed, 122 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py index 863b2833851..e41c3d02e9e 100644 --- a/homeassistant/components/private_ble_device/coordinator.py +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -102,6 +102,16 @@ class PrivateDevicesCoordinator: def _async_irk_resolved_to_mac(self, irk: bytes, mac: str) -> None: if previous_mac := self._irk_to_mac.get(irk): + previous_interval = bluetooth.async_get_learned_advertising_interval( + self.hass, previous_mac + ) or bluetooth.async_get_fallback_availability_interval( + self.hass, previous_mac + ) + if previous_interval: + bluetooth.async_set_fallback_availability_interval( + self.hass, mac, previous_interval + ) + self._mac_to_irk.pop(previous_mac, None) self._mac_to_irk[mac] = irk diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index e2f5efb6699..b332d057ba9 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfLength, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,7 +30,9 @@ from .entity import BasePrivateDeviceEntity class PrivateDeviceSensorEntityDescriptionRequired: """Required domain specific fields for sensor entity.""" - value_fn: Callable[[bluetooth.BluetoothServiceInfoBleak], str | int | float | None] + value_fn: Callable[ + [HomeAssistant, bluetooth.BluetoothServiceInfoBleak], str | int | float | None + ] @dataclass @@ -46,7 +49,7 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda service_info: service_info.advertisement.rssi, + value_fn=lambda _, service_info: service_info.advertisement.rssi, state_class=SensorStateClass.MEASUREMENT, ), PrivateDeviceSensorEntityDescription( @@ -56,7 +59,7 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda service_info: service_info.advertisement.tx_power, + value_fn=lambda _, service_info: service_info.advertisement.tx_power, state_class=SensorStateClass.MEASUREMENT, ), PrivateDeviceSensorEntityDescription( @@ -64,7 +67,7 @@ SENSOR_DESCRIPTIONS = ( translation_key="estimated_distance", icon="mdi:signal-distance-variant", native_unit_of_measurement=UnitOfLength.METERS, - value_fn=lambda service_info: service_info.advertisement + value_fn=lambda _, service_info: service_info.advertisement and service_info.advertisement.tx_power and calculate_distance_meters( service_info.advertisement.tx_power * 10, service_info.advertisement.rssi @@ -73,6 +76,22 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, ), + PrivateDeviceSensorEntityDescription( + key="estimated_broadcast_interval", + translation_key="estimated_broadcast_interval", + icon="mdi:timer-sync-outline", + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda hass, service_info: bluetooth.async_get_learned_advertising_interval( + hass, service_info.address + ) + or bluetooth.async_get_fallback_availability_interval( + hass, service_info.address + ) + or bluetooth.FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + suggested_display_precision=1, + ), ) @@ -124,4 +143,4 @@ class PrivateBLEDeviceSensor(BasePrivateDeviceEntity, SensorEntity): def native_value(self) -> str | int | float | None: """Return the state of the sensor.""" assert self._last_info - return self.entity_description.value_fn(self._last_info) + return self.entity_description.value_fn(self.hass, self._last_info) diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index 279ff38bc9b..9e20a9476ec 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -24,6 +24,9 @@ }, "estimated_distance": { "name": "Estimated distance" + }, + "estimated_broadcast_interval": { + "name": "Estimated broadcast interval" } } } diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index 776ba503983..d8b30738865 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -6,6 +6,9 @@ import time from homeassistant.components.bluetooth.advertisement_tracker import ( ADVERTISING_TIMES_NEEDED, ) +from homeassistant.components.bluetooth.api import ( + async_get_fallback_availability_interval, +) from homeassistant.core import HomeAssistant from . import ( @@ -181,3 +184,23 @@ async def test_old_tracker_leave_home( state = hass.states.get("device_tracker.private_ble_device_000000") assert state assert state.state == "not_home" + + +async def test_mac_rotation( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we receive a broadcast.""" + await async_mock_config_entry(hass) + + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_1) is None + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_2) is None + + for i in range(ADVERTISING_TIMES_NEEDED): + await async_inject_broadcast( + hass, MAC_RPA_VALID_1, mfr_data=bytes(i), broadcast_time=i * 10 + ) + + await async_inject_broadcast(hass, MAC_RPA_VALID_2) + assert async_get_fallback_availability_interval(hass, MAC_RPA_VALID_2) == 10 diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 820ec2199ad..65f08d5653d 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,9 +1,18 @@ """Tests for sensors.""" +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 MAC_RPA_VALID_1, async_inject_broadcast, async_mock_config_entry +from . import ( + MAC_RPA_VALID_1, + MAC_RPA_VALID_2, + async_inject_broadcast, + async_mock_config_entry, +) async def test_sensor_unavailable( @@ -45,3 +54,55 @@ async def test_sensors_come_home( state = hass.states.get("sensor.private_ble_device_000000_signal_strength") assert state assert state.state == "-63" + + +async def test_estimated_broadcast_interval( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we receive a broadcast.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + + # With no fallback and no learned interval, we should use the global default + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "900" + + # Fallback interval trumps const default + + async_set_fallback_availability_interval(hass, MAC_RPA_VALID_1, 90) + await async_inject_broadcast(hass, MAC_RPA_VALID_1.upper()) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "90" + + # Learned broadcast interval takes over from fallback interval + + for i in range(ADVERTISING_TIMES_NEEDED): + await async_inject_broadcast( + hass, MAC_RPA_VALID_1, mfr_data=bytes(i), broadcast_time=i * 10 + ) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "10" + + # MAC address changes, the broadcast interval is kept + + await async_inject_broadcast(hass, MAC_RPA_VALID_2.upper()) + + state = hass.states.get( + "sensor.private_ble_device_000000_estimated_broadcast_interval" + ) + assert state + assert state.state == "10" From b9e8566608d68a3d8fc9407a351ec4469675bdda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 24 Sep 2023 20:29:29 +0200 Subject: [PATCH 742/984] Bump bluetooth-data-tools to 0.12.0 (#100794) --- 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 56b06cd9d35..def08cb914c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.2.1", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.11.0", + "bluetooth-data-tools==1.12.0", "dbus-fast==2.9.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 65c5bf44d5b..b8bedce9556 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "async-interrupt==1.1.1", "aioesphomeapi==16.0.5", - "bluetooth-data-tools==1.11.0", + "bluetooth-data-tools==1.12.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 798a80147de..7971f6bfaf4 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.11.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.12.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index da5b4b0a4ee..d69b709c6be 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.11.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.12.0", "led-ble==1.0.0"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 3497138178c..9900c854657 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.11.0"] + "requirements": ["bluetooth-data-tools==1.12.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ff1c558a8d6..3017327d1df 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.2.1 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.11.0 +bluetooth-data-tools==1.12.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index 13ad421df96..653628118ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.11.0 +bluetooth-data-tools==1.12.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f1e3adc781..3422050523b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -468,7 +468,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.11.0 +bluetooth-data-tools==1.12.0 # homeassistant.components.bond bond-async==0.2.1 From 5549f697cf55a5e785b3828df7b7c1810f812087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 24 Sep 2023 22:18:31 +0200 Subject: [PATCH 743/984] Update AEMET-OpenData to v0.4.5 (#100818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/aemet/__init__.py | 2 +- homeassistant/components/aemet/config_flow.py | 2 +- homeassistant/components/aemet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index c8b3f774a97..bcddce5868c 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -30,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = entry.data[CONF_LONGITUDE] station_updates = entry.options.get(CONF_STATION_UPDATES, True) - options = ConnectionOptions(api_key, station_updates) + options = ConnectionOptions(api_key, station_updates, True) aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) try: await aemet.select_coordinates(latitude, longitude) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 4df25613803..dbf3df823e3 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -40,7 +40,7 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - options = ConnectionOptions(user_input[CONF_API_KEY], False) + options = ConnectionOptions(user_input[CONF_API_KEY], False, True) aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options) try: await aemet.select_coordinates(latitude, longitude) diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 1c65572a64e..74d53cc117a 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.4.4"] + "requirements": ["AEMET-OpenData==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 653628118ba..d352f4d07e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2,7 +2,7 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.4 +AEMET-OpenData==0.4.5 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3422050523b..45d0958ed31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.4.4 +AEMET-OpenData==0.4.5 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.58 From 66ebb479ea032a508077c166f712c339f2ab77a6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 24 Sep 2023 13:37:48 -0700 Subject: [PATCH 744/984] Rewrite fitbit sensor API response value parsing (#100782) * Cleanup fitbit sensor API parsing * Remove API code that is not used yet * Remove dead code for battery levels Small API parsing cleanup * Address PR feedback * Update homeassistant/components/fitbit/sensor.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/fitbit/api.py | 65 ++++++++ homeassistant/components/fitbit/model.py | 37 +++++ homeassistant/components/fitbit/sensor.py | 177 ++++++++++++---------- 3 files changed, 198 insertions(+), 81 deletions(-) create mode 100644 homeassistant/components/fitbit/api.py create mode 100644 homeassistant/components/fitbit/model.py diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py new file mode 100644 index 00000000000..19f6965a4bb --- /dev/null +++ b/homeassistant/components/fitbit/api.py @@ -0,0 +1,65 @@ +"""API for fitbit bound to Home Assistant OAuth.""" + +import logging +from typing import Any + +from fitbit import Fitbit + +from homeassistant.core import HomeAssistant + +from .model import FitbitDevice, FitbitProfile + +_LOGGER = logging.getLogger(__name__) + + +class FitbitApi: + """Fitbit client library wrapper base class.""" + + def __init__( + self, + hass: HomeAssistant, + client: Fitbit, + ) -> None: + """Initialize Fitbit auth.""" + self._hass = hass + self._profile: FitbitProfile | None = None + self._client = client + + @property + def client(self) -> Fitbit: + """Property to expose the underlying client library.""" + return self._client + + def get_user_profile(self) -> FitbitProfile: + """Return the user profile from the API.""" + response: dict[str, Any] = self._client.user_profile_get() + _LOGGER.debug("user_profile_get=%s", response) + profile = response["user"] + return FitbitProfile( + encoded_id=profile["encodedId"], + full_name=profile["fullName"], + locale=profile.get("locale"), + ) + + def get_devices(self) -> list[FitbitDevice]: + """Return available devices.""" + devices: list[dict[str, str]] = self._client.get_devices() + _LOGGER.debug("get_devices=%s", devices) + return [ + FitbitDevice( + id=device["id"], + device_version=device["deviceVersion"], + battery_level=int(device["batteryLevel"]), + battery=device["battery"], + type=device["type"], + ) + for device in devices + ] + + def get_latest_time_series(self, resource_type: str) -> dict[str, Any]: + """Return the most recent value from the time series for the specified resource type.""" + response: dict[str, Any] = self._client.time_series(resource_type, period="7d") + _LOGGER.debug("time_series(%s)=%s", resource_type, response) + key = resource_type.replace("/", "-") + dated_results: list[dict[str, Any]] = response[key] + return dated_results[-1] diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py new file mode 100644 index 00000000000..3d321d8dd01 --- /dev/null +++ b/homeassistant/components/fitbit/model.py @@ -0,0 +1,37 @@ +"""Data representation for fitbit API responses.""" + +from dataclasses import dataclass + + +@dataclass +class FitbitProfile: + """User profile from the Fitbit API response.""" + + encoded_id: str + """The ID representing the Fitbit user.""" + + full_name: str + """The first name value specified in the user's account settings.""" + + locale: str | None + """The locale defined in the user's Fitbit account settings.""" + + +@dataclass +class FitbitDevice: + """Device from the Fitbit API response.""" + + id: str + """The device ID.""" + + device_version: str + """The product name of the device.""" + + battery_level: int + """The battery level as a percentage.""" + + battery: str + """Returns the battery level of the device.""" + + type: str + """The type of the device such as TRACKER or SCALE.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 6c93fbe35c1..3b1c831b116 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,6 +1,7 @@ """Support for the Fitbit API.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import datetime import logging @@ -40,6 +41,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json_object from homeassistant.util.unit_system import METRIC_SYSTEM +from .api import FitbitApi from .const import ( ATTR_ACCESS_TOKEN, ATTR_LAST_SAVED_AT, @@ -56,6 +58,7 @@ from .const import ( FITBIT_DEFAULT_RESOURCES, FITBIT_MEASUREMENTS, ) +from .model import FitbitDevice, FitbitProfile _LOGGER: Final = logging.getLogger(__name__) @@ -64,11 +67,42 @@ _CONFIGURING: dict[str, str] = {} SCAN_INTERVAL: Final = datetime.timedelta(minutes=30) +def _default_value_fn(result: dict[str, Any]) -> str: + """Parse a Fitbit timeseries API responses.""" + return cast(str, result["value"]) + + +def _distance_value_fn(result: dict[str, Any]) -> int | str: + """Format function for distance values.""" + return format(float(_default_value_fn(result)), ".2f") + + +def _body_value_fn(result: dict[str, Any]) -> int | str: + """Format function for body values.""" + return format(float(_default_value_fn(result)), ".1f") + + +def _clock_format_12h(result: dict[str, Any]) -> str: + raw_state = result["value"] + if raw_state == "": + return "-" + hours_str, minutes_str = raw_state.split(":") + hours, minutes = int(hours_str), int(minutes_str) + setting = "AM" + if hours > 12: + setting = "PM" + hours -= 12 + elif hours == 0: + hours = 12 + return f"{hours}:{minutes:02d} {setting}" + + @dataclass class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" unit_type: str | None = None + value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -96,6 +130,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( unit_type="distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, + value_fn=_distance_value_fn, ), FitbitSensorEntityDescription( key="activities/elevation", @@ -115,6 +150,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"]), ), FitbitSensorEntityDescription( key="activities/minutesFairlyActive", @@ -168,6 +204,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( unit_type="distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, + value_fn=_distance_value_fn, ), FitbitSensorEntityDescription( key="activities/tracker/elevation", @@ -222,6 +259,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="BMI", icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, + value_fn=_body_value_fn, ), FitbitSensorEntityDescription( key="body/fat", @@ -229,6 +267,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, + value_fn=_body_value_fn, ), FitbitSensorEntityDescription( key="body/weight", @@ -237,6 +276,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WEIGHT, + value_fn=_body_value_fn, ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", @@ -279,11 +319,6 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, ), - FitbitSensorEntityDescription( - key="sleep/startTime", - name="Sleep Start Time", - icon="mdi:clock", - ), FitbitSensorEntityDescription( key="sleep/timeInBed", name="Sleep Time in Bed", @@ -293,6 +328,19 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( ), ) +# Different description depending on clock format +SLEEP_START_TIME = FitbitSensorEntityDescription( + key="sleep/startTime", + name="Sleep Start Time", + icon="mdi:clock", +) +SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( + key="sleep/startTime", + name="Sleep Start Time", + icon="mdi:clock", + value_fn=_clock_format_12h, +) + FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", name="Battery", @@ -300,7 +348,8 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( ) FITBIT_RESOURCES_KEYS: Final[list[str]] = [ - desc.key for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY) + desc.key + for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY, SLEEP_START_TIME) ] PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( @@ -438,9 +487,10 @@ def setup_platform( if int(time.time()) - cast(int, expires_at) > 3600: authd_client.client.refresh_token() - user_profile = authd_client.user_profile_get()["user"] + api = FitbitApi(hass, authd_client) + user_profile = api.get_user_profile() if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": - authd_client.system = user_profile["locale"] + authd_client.system = user_profile.locale if authd_client.system != "en_GB": if hass.config.units is METRIC_SYSTEM: authd_client.system = "metric" @@ -449,34 +499,38 @@ def setup_platform( else: authd_client.system = unit_system - registered_devs = authd_client.get_devices() clock_format = config[CONF_CLOCK_FORMAT] monitored_resources = config[CONF_MONITORED_RESOURCES] + resource_list = [ + *FITBIT_RESOURCES_LIST, + SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, + ] entities = [ FitbitSensor( - authd_client, + api, user_profile, config_path, description, hass.config.units is METRIC_SYSTEM, clock_format, ) - for description in FITBIT_RESOURCES_LIST + for description in resource_list if description.key in monitored_resources ] if "devices/battery" in monitored_resources: + devices = api.get_devices() entities.extend( [ FitbitSensor( - authd_client, + api, user_profile, config_path, FITBIT_RESOURCE_BATTERY, hass.config.units is METRIC_SYSTEM, clock_format, - dev_extra, + device, ) - for dev_extra in registered_devs + for device in devices ] ) add_entities(entities, True) @@ -591,30 +645,30 @@ class FitbitSensor(SensorEntity): def __init__( self, - client: Fitbit, - user_profile: dict[str, Any], + api: FitbitApi, + user_profile: FitbitProfile, config_path: str, description: FitbitSensorEntityDescription, is_metric: bool, clock_format: str, - extra: dict[str, str] | None = None, + device: FitbitDevice | None = None, ) -> None: """Initialize the Fitbit sensor.""" self.entity_description = description - self.client = client + self.api = api self.config_path = config_path self.is_metric = is_metric self.clock_format = clock_format - self.extra = extra + self.device = device - self._attr_unique_id = f"{user_profile['encodedId']}_{description.key}" - if self.extra is not None: - self._attr_name = f"{self.extra.get('deviceVersion')} Battery" - self._attr_unique_id = f"{self._attr_unique_id}_{self.extra.get('id')}" + self._attr_unique_id = f"{user_profile.encoded_id}_{description.key}" + if device is not None: + self._attr_name = f"{device.device_version} Battery" + self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" if description.unit_type: try: - measurement_system = FITBIT_MEASUREMENTS[self.client.system] + measurement_system = FITBIT_MEASUREMENTS[self.api.client.system] except KeyError: if self.is_metric: measurement_system = FITBIT_MEASUREMENTS["metric"] @@ -629,9 +683,8 @@ class FitbitSensor(SensorEntity): """Icon to use in the frontend, if any.""" if ( self.entity_description.key == "devices/battery" - and self.extra is not None - and (extra_battery := self.extra.get("battery")) is not None - and (battery_level := BATTERY_LEVELS.get(extra_battery)) is not None + and self.device is not None + and (battery_level := BATTERY_LEVELS.get(self.device.battery)) is not None ): return icon_for_battery_level(battery_level=battery_level) return self.entity_description.icon @@ -641,72 +694,34 @@ class FitbitSensor(SensorEntity): """Return the state attributes.""" attrs: dict[str, str | None] = {} - if self.extra is not None: - attrs["model"] = self.extra.get("deviceVersion") - extra_type = self.extra.get("type") - attrs["type"] = extra_type.lower() if extra_type is not None else None + if self.device is not None: + attrs["model"] = self.device.device_version + device_type = self.device.type + attrs["type"] = device_type.lower() if device_type is not None else None return attrs def update(self) -> None: """Get the latest data from the Fitbit API and update the states.""" resource_type = self.entity_description.key - if resource_type == "devices/battery" and self.extra is not None: - registered_devs: list[dict[str, Any]] = self.client.get_devices() - device_id = self.extra.get("id") - self.extra = list( - filter(lambda device: device.get("id") == device_id, registered_devs) - )[0] - self._attr_native_value = self.extra.get("battery") + if resource_type == "devices/battery" and self.device is not None: + device_id = self.device.id + registered_devs: list[FitbitDevice] = self.api.get_devices() + self.device = next( + device for device in registered_devs if device.id == device_id + ) + self._attr_native_value = self.device.battery else: - container = resource_type.replace("/", "-") - response = self.client.time_series(resource_type, period="7d") - raw_state = response[container][-1].get("value") - if resource_type == "activities/distance": - self._attr_native_value = format(float(raw_state), ".2f") - elif resource_type == "activities/tracker/distance": - self._attr_native_value = format(float(raw_state), ".2f") - elif resource_type == "body/bmi": - self._attr_native_value = format(float(raw_state), ".1f") - elif resource_type == "body/fat": - self._attr_native_value = format(float(raw_state), ".1f") - elif resource_type == "body/weight": - self._attr_native_value = format(float(raw_state), ".1f") - elif resource_type == "sleep/startTime": - if raw_state == "": - self._attr_native_value = "-" - elif self.clock_format == "12H": - hours, minutes = raw_state.split(":") - hours, minutes = int(hours), int(minutes) - setting = "AM" - if hours > 12: - setting = "PM" - hours -= 12 - elif hours == 0: - hours = 12 - self._attr_native_value = f"{hours}:{minutes:02d} {setting}" - else: - self._attr_native_value = raw_state - elif self.is_metric: - self._attr_native_value = raw_state - else: - try: - self._attr_native_value = int(raw_state) - except TypeError: - self._attr_native_value = raw_state + result = self.api.get_latest_time_series(resource_type) + self._attr_native_value = self.entity_description.value_fn(result) - if resource_type == "activities/heart": - self._attr_native_value = ( - response[container][-1].get("value").get("restingHeartRate") - ) - - token = self.client.client.session.token + token = self.api.client.client.session.token config_contents = { ATTR_ACCESS_TOKEN: token.get("access_token"), ATTR_REFRESH_TOKEN: token.get("refresh_token"), - CONF_CLIENT_ID: self.client.client.client_id, - CONF_CLIENT_SECRET: self.client.client.client_secret, + CONF_CLIENT_ID: self.api.client.client.client_id, + CONF_CLIENT_SECRET: self.api.client.client.client_secret, ATTR_LAST_SAVED_AT: int(time.time()), } save_json(self.config_path, config_contents) From 6d624ecb46baa9f80fcef48b7d110fc3845f5eac Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 24 Sep 2023 14:46:43 -0600 Subject: [PATCH 745/984] Bump pylitterbot to 2023.4.8 (#100811) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 9a3334cbaac..fd37365eb7d 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.5"] + "requirements": ["pylitterbot==2023.4.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index d352f4d07e2..462d02e9e80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1828,7 +1828,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.5 +pylitterbot==2023.4.8 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45d0958ed31..e2ac88724ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1371,7 +1371,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.5 +pylitterbot==2023.4.8 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 From 09729e8c46f60d3e9dc31dce2e9d3c1eef0aab46 Mon Sep 17 00:00:00 2001 From: Daniel Trnka Date: Sun, 24 Sep 2023 22:50:13 +0200 Subject: [PATCH 746/984] Add Mysensors battery sensor (#100749) * Move child related stuff to MySensorsChildEntity * Dispatch signal for newly discovered MySensors node * Create battery entity for each MySensors node * Removed ATTR_BATTERY_LEVEL attribute from each node sensor Attribute is redundant with newly introduced battery sensor entity * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare --- .../components/mysensors/__init__.py | 18 +- .../components/mysensors/binary_sensor.py | 2 +- homeassistant/components/mysensors/climate.py | 2 +- homeassistant/components/mysensors/const.py | 10 + homeassistant/components/mysensors/cover.py | 2 +- homeassistant/components/mysensors/device.py | 237 +++++++++--------- .../components/mysensors/device_tracker.py | 4 +- homeassistant/components/mysensors/gateway.py | 2 + homeassistant/components/mysensors/handler.py | 17 +- homeassistant/components/mysensors/helpers.py | 24 ++ homeassistant/components/mysensors/light.py | 6 +- homeassistant/components/mysensors/remote.py | 4 +- homeassistant/components/mysensors/sensor.py | 59 ++++- homeassistant/components/mysensors/switch.py | 4 +- homeassistant/components/mysensors/text.py | 4 +- tests/components/mysensors/conftest.py | 16 ++ .../fixtures/battery_sensor_state.json | 12 + tests/components/mysensors/test_sensor.py | 19 ++ 18 files changed, 303 insertions(+), 139 deletions(-) create mode 100644 tests/components/mysensors/fixtures/battery_sensor_state.json diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 5b8154e17aa..a3f52cd28ab 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -15,6 +15,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import ( ATTR_DEVICES, DOMAIN, + MYSENSORS_DISCOVERED_NODES, MYSENSORS_GATEWAYS, MYSENSORS_ON_UNLOAD, PLATFORMS, @@ -22,7 +23,7 @@ from .const import ( DiscoveryInfo, SensorType, ) -from .device import MySensorsEntity, get_mysensors_devices +from .device import MySensorsChildEntity, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) @@ -72,6 +73,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(key) del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] + hass.data[DOMAIN].pop(MYSENSORS_DISCOVERED_NODES.format(entry.entry_id), None) await gw_stop(hass, entry, gateway) return True @@ -91,6 +93,11 @@ async def async_remove_config_entry_device( gateway.sensors.pop(node_id, None) gateway.tasks.persistence.need_save = True + # remove node from discovered nodes + hass.data[DOMAIN].setdefault( + MYSENSORS_DISCOVERED_NODES.format(config_entry.entry_id), set() + ).remove(node_id) + return True @@ -99,12 +106,13 @@ def setup_mysensors_platform( hass: HomeAssistant, domain: Platform, # hass platform name discovery_info: DiscoveryInfo, - device_class: type[MySensorsEntity] | Mapping[SensorType, type[MySensorsEntity]], + device_class: type[MySensorsChildEntity] + | Mapping[SensorType, type[MySensorsChildEntity]], device_args: ( None | tuple ) = None, # extra arguments that will be given to the entity constructor async_add_entities: Callable | None = None, -) -> list[MySensorsEntity] | None: +) -> list[MySensorsChildEntity] | None: """Set up a MySensors platform. Sets up a bunch of instances of a single platform that is supported by this @@ -118,10 +126,10 @@ def setup_mysensors_platform( """ if device_args is None: device_args = () - new_devices: list[MySensorsEntity] = [] + new_devices: list[MySensorsChildEntity] = [] new_dev_ids: list[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices: dict[DevId, MySensorsEntity] = get_mysensors_devices(hass, domain) + devices: dict[DevId, MySensorsChildEntity] = get_mysensors_devices(hass, domain) if dev_id in devices: _LOGGER.debug( "Skipping setup of %s for platform %s as it already exists", diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index d8f4ec07cb2..2b4edd99221 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -95,7 +95,7 @@ async def async_setup_entry( ) -class MySensorsBinarySensor(mysensors.device.MySensorsEntity, BinarySensorEntity): +class MySensorsBinarySensor(mysensors.device.MySensorsChildEntity, BinarySensorEntity): """Representation of a MySensors binary sensor child node.""" entity_description: MySensorsBinarySensorDescription diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d207d7ff550..e9d4502242e 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -66,7 +66,7 @@ async def async_setup_entry( ) -class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): +class MySensorsHVAC(mysensors.device.MySensorsChildEntity, ClimateEntity): """Representation of a MySensors HVAC.""" _attr_hvac_modes = OPERATION_LIST diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 7f9326091fe..a5c82c32b55 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -8,6 +8,7 @@ from homeassistant.const import Platform ATTR_DEVICES: Final = "devices" ATTR_GATEWAY_ID: Final = "gateway_id" +ATTR_NODE_ID: Final = "node_id" CONF_BAUD_RATE: Final = "baud_rate" CONF_DEVICE: Final = "device" @@ -26,11 +27,13 @@ CONF_GATEWAY_TYPE_MQTT: ConfGatewayType = "MQTT" DOMAIN: Final = "mysensors" MYSENSORS_GATEWAY_START_TASK: str = "mysensors_gateway_start_task_{}" MYSENSORS_GATEWAYS: Final = "mysensors_gateways" +MYSENSORS_DISCOVERED_NODES: Final = "mysensors_discovered_nodes_{}" PLATFORM: Final = "platform" SCHEMA: Final = "schema" CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" MYSENSORS_DISCOVERY: str = "mysensors_discovery_{}_{}" +MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery" MYSENSORS_ON_UNLOAD: str = "mysensors_on_unload_{}" TYPE: Final = "type" UPDATE_DELAY: float = 0.1 @@ -43,6 +46,13 @@ class DiscoveryInfo(TypedDict): gateway_id: GatewayId +class NodeDiscoveryInfo(TypedDict): + """Represent discovered mysensors node.""" + + gateway_id: GatewayId + node_id: int + + SERVICE_SEND_IR_CODE: Final = "send_ir_code" SensorType = str diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index a1b2cb303ed..8be5f1f8620 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -54,7 +54,7 @@ async def async_setup_entry( ) -class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): +class MySensorsCover(mysensors.device.MySensorsChildEntity, CoverEntity): """Representation of the value of a MySensors Cover child node.""" def get_cover_state(self) -> CoverState: diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index a89de3abf69..9e1d91c7cce 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -1,14 +1,14 @@ """Handle MySensors devices.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod import logging from typing import Any from mysensors import BaseAsyncGateway, Sensor from mysensors.sensor import ChildSensor -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -36,56 +36,24 @@ ATTR_HEARTBEAT = "heartbeat" MYSENSORS_PLATFORM_DEVICES = "mysensors_devices_{}" -class MySensorsDevice(ABC): +class MySensorNodeEntity(Entity): """Representation of a MySensors device.""" hass: HomeAssistant def __init__( - self, - gateway_id: GatewayId, - gateway: BaseAsyncGateway, - node_id: int, - child_id: int, - value_type: int, + self, gateway_id: GatewayId, gateway: BaseAsyncGateway, node_id: int ) -> None: - """Set up the MySensors device.""" + """Set up the MySensors node entity.""" self.gateway_id: GatewayId = gateway_id self.gateway: BaseAsyncGateway = gateway self.node_id: int = node_id - self.child_id: int = child_id - # value_type as int. string variant can be looked up in gateway consts - self.value_type: int = value_type - self.child_type = self._child.type - self._values: dict[int, Any] = {} self._debouncer: Debouncer | None = None - @property - def dev_id(self) -> DevId: - """Return the DevId of this device. - - It is used to route incoming MySensors messages to the correct device/entity. - """ - return self.gateway_id, self.node_id, self.child_id, self.value_type - - async def async_will_remove_from_hass(self) -> None: - """Remove this entity from home assistant.""" - for platform in PLATFORM_TYPES: - platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) - if platform_str in self.hass.data[DOMAIN]: - platform_dict = self.hass.data[DOMAIN][platform_str] - if self.dev_id in platform_dict: - del platform_dict[self.dev_id] - _LOGGER.debug("Deleted %s from platform %s", self.dev_id, platform) - @property def _node(self) -> Sensor: return self.gateway.sensors[self.node_id] - @property - def _child(self) -> ChildSensor: - return self._node.children[self.child_id] - @property def sketch_name(self) -> str: """Return the name of the sketch running on the whole node. @@ -110,11 +78,6 @@ class MySensorsDevice(ABC): """ return f"{self.sketch_name} {self.node_id}" - @property - def unique_id(self) -> str: - """Return a unique ID for use in home assistant.""" - return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}" - @property def device_info(self) -> DeviceInfo: """Return the device info.""" @@ -125,6 +88,96 @@ class MySensorsDevice(ABC): sw_version=self.sketch_version, ) + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device specific attributes.""" + node = self.gateway.sensors[self.node_id] + + return { + ATTR_HEARTBEAT: node.heartbeat, + ATTR_NODE_ID: self.node_id, + } + + @callback + @abstractmethod + def _async_update_callback(self) -> None: + """Update the device.""" + + async def async_update_callback(self) -> None: + """Update the device after delay.""" + if not self._debouncer: + self._debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=UPDATE_DELAY, + immediate=False, + function=self._async_update_callback, + ) + + await self._debouncer.async_call() + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + NODE_CALLBACK.format(self.gateway_id, self.node_id), + self.async_update_callback, + ) + ) + self._async_update_callback() + + +def get_mysensors_devices( + hass: HomeAssistant, domain: Platform +) -> dict[DevId, MySensorsChildEntity]: + """Return MySensors devices for a hass platform name.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + devices: dict[DevId, MySensorsChildEntity] = hass.data[DOMAIN][ + MYSENSORS_PLATFORM_DEVICES.format(domain) + ] + return devices + + +class MySensorsChildEntity(MySensorNodeEntity): + """Representation of a MySensors entity.""" + + _attr_should_poll = False + + def __init__( + self, + gateway_id: GatewayId, + gateway: BaseAsyncGateway, + node_id: int, + child_id: int, + value_type: int, + ) -> None: + """Set up the MySensors child entity.""" + super().__init__(gateway_id, gateway, node_id) + self.child_id: int = child_id + # value_type as int. string variant can be looked up in gateway consts + self.value_type: int = value_type + self.child_type = self._child.type + self._values: dict[int, Any] = {} + + @property + def dev_id(self) -> DevId: + """Return the DevId of this device. + + It is used to route incoming MySensors messages to the correct device/entity. + """ + return self.gateway_id, self.node_id, self.child_id, self.value_type + + @property + def _child(self) -> ChildSensor: + return self._node.children[self.child_id] + + @property + def unique_id(self) -> str: + """Return a unique ID for use in home assistant.""" + return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}" + @property def name(self) -> str: """Return the name of this entity.""" @@ -134,21 +187,33 @@ class MySensorsDevice(ABC): return str(child.description) return f"{self.node_name} {self.child_id}" + async def async_will_remove_from_hass(self) -> None: + """Remove this entity from home assistant.""" + for platform in PLATFORM_TYPES: + platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) + if platform_str in self.hass.data[DOMAIN]: + platform_dict = self.hass.data[DOMAIN][platform_str] + if self.dev_id in platform_dict: + del platform_dict[self.dev_id] + _LOGGER.debug("Deleted %s from platform %s", self.dev_id, platform) + @property - def _extra_attributes(self) -> dict[str, Any]: - """Return device specific attributes.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - attr = { - ATTR_BATTERY_LEVEL: node.battery_level, - ATTR_HEARTBEAT: node.heartbeat, - ATTR_CHILD_ID: self.child_id, - ATTR_DESCRIPTION: child.description, - ATTR_NODE_ID: self.node_id, - } + def available(self) -> bool: + """Return true if entity is available.""" + return self.value_type in self._values + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity and device specific state attributes.""" + attr = super().extra_state_attributes + + assert self.platform.config_entry + attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] + + attr[ATTR_CHILD_ID] = self.child_id + attr[ATTR_DESCRIPTION] = self._child.description set_req = self.gateway.const.SetReq - for value_type, value in self._values.items(): attr[set_req(value_type).name] = value @@ -157,10 +222,8 @@ class MySensorsDevice(ABC): @callback def _async_update(self) -> None: """Update the controller with the latest value from a sensor.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] set_req = self.gateway.const.SetReq - for value_type, value in child.values.items(): + for value_type, value in self._child.values.items(): _LOGGER.debug( "Entity update: %s: value_type %s, value = %s", self.name, @@ -182,57 +245,6 @@ class MySensorsDevice(ABC): else: self._values[value_type] = value - @callback - @abstractmethod - def _async_update_callback(self) -> None: - """Update the device.""" - - async def async_update_callback(self) -> None: - """Update the device after delay.""" - if not self._debouncer: - self._debouncer = Debouncer( - self.hass, - _LOGGER, - cooldown=UPDATE_DELAY, - immediate=False, - function=self._async_update_callback, - ) - - await self._debouncer.async_call() - - -def get_mysensors_devices( - hass: HomeAssistant, domain: Platform -) -> dict[DevId, MySensorsEntity]: - """Return MySensors devices for a hass platform name.""" - if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: - hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - devices: dict[DevId, MySensorsEntity] = hass.data[DOMAIN][ - MYSENSORS_PLATFORM_DEVICES.format(domain) - ] - return devices - - -class MySensorsEntity(MySensorsDevice, Entity): - """Representation of a MySensors entity.""" - - _attr_should_poll = False - - @property - def available(self) -> bool: - """Return true if entity is available.""" - return self.value_type in self._values - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return entity specific state attributes.""" - attr = self._extra_attributes - - assert self.platform.config_entry - attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] - - return attr - @callback def _async_update_callback(self) -> None: """Update the entity.""" @@ -241,6 +253,7 @@ class MySensorsEntity(MySensorsDevice, Entity): async def async_added_to_hass(self) -> None: """Register update callback.""" + await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, @@ -248,11 +261,3 @@ class MySensorsEntity(MySensorsDevice, Entity): self.async_update_callback, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - NODE_CALLBACK.format(self.gateway_id, self.node_id), - self.async_update_callback, - ) - ) - self._async_update() diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 920645a229a..d56e9874560 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class MySensorsDeviceTracker(MySensorsEntity, TrackerEntity): +class MySensorsDeviceTracker(MySensorsChildEntity, TrackerEntity): """Represent a MySensors device tracker.""" _latitude: float | None = None diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index ce602e6266d..590ad41d6a2 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -42,6 +42,7 @@ from .const import ( ) from .handler import HANDLERS from .helpers import ( + discover_mysensors_node, discover_mysensors_platform, on_unload, validate_child, @@ -244,6 +245,7 @@ async def _discover_persistent_devices( for node_id in gateway.sensors: if not validate_node(gateway, node_id): continue + discover_mysensors_node(hass, entry.entry_id, node_id) node: Sensor = gateway.sensors[node_id] for child in node.children.values(): # child is of type ChildSensor validated = validate_child(entry.entry_id, gateway, node_id, child) diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index 8a77d167f8b..aa8a235c7cb 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from mysensors import Message +from mysensors.const import SYSTEM_CHILD_ID from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback @@ -12,7 +13,11 @@ from homeassistant.util import decorator from .const import CHILD_CALLBACK, NODE_CALLBACK, DevId, GatewayId from .device import get_mysensors_devices -from .helpers import discover_mysensors_platform, validate_set_msg +from .helpers import ( + discover_mysensors_node, + discover_mysensors_platform, + validate_set_msg, +) HANDLERS: decorator.Registry[ str, Callable[[HomeAssistant, GatewayId, Message], None] @@ -71,6 +76,16 @@ def handle_sketch_version( _handle_node_update(hass, gateway_id, msg) +@HANDLERS.register("presentation") +@callback +def handle_presentation( + hass: HomeAssistant, gateway_id: GatewayId, msg: Message +) -> None: + """Handle an internal presentation message.""" + if msg.child_id == SYSTEM_CHILD_ID: + discover_mysensors_node(hass, gateway_id, msg.node_id) + + @callback def _handle_child_update( hass: HomeAssistant, gateway_id: GatewayId, validated: dict[Platform, list[DevId]] diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index a5f67111738..9985929eecd 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -19,9 +19,12 @@ from homeassistant.util.decorator import Registry from .const import ( ATTR_DEVICES, ATTR_GATEWAY_ID, + ATTR_NODE_ID, DOMAIN, FLAT_PLATFORM_TYPES, + MYSENSORS_DISCOVERED_NODES, MYSENSORS_DISCOVERY, + MYSENSORS_NODE_DISCOVERY, MYSENSORS_ON_UNLOAD, TYPE_TO_PLATFORMS, DevId, @@ -65,6 +68,27 @@ def discover_mysensors_platform( ) +@callback +def discover_mysensors_node( + hass: HomeAssistant, gateway_id: GatewayId, node_id: int +) -> None: + """Discover a MySensors node.""" + discovered_nodes = hass.data[DOMAIN].setdefault( + MYSENSORS_DISCOVERED_NODES.format(gateway_id), set() + ) + + if node_id not in discovered_nodes: + discovered_nodes.add(node_id) + async_dispatcher_send( + hass, + MYSENSORS_NODE_DISCOVERY, + { + ATTR_GATEWAY_ID: gateway_id, + ATTR_NODE_ID: node_id, + }, + ) + + def default_schema( gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType ) -> vol.Schema: diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 213e268696e..7aea1e906a6 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -19,7 +19,7 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsEntity]] = { + device_class_map: dict[SensorType, type[MySensorsChildEntity]] = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, @@ -56,7 +56,7 @@ async def async_setup_entry( ) -class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): +class MySensorsLight(mysensors.device.MySensorsChildEntity, LightEntity): """Representation of a MySensors Light child node.""" def __init__(self, *args: Any) -> None: diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index d72bbfa4235..8521e407ae1 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -50,7 +50,7 @@ async def async_setup_entry( ) -class MySensorsRemote(MySensorsEntity, RemoteEntity): +class MySensorsRemote(MySensorsChildEntity, RemoteEntity): """Representation of a MySensors IR transceiver.""" _current_command: str | None = None diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 174b1f094b1..84ae1ed031f 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from awesomeversion import AwesomeVersion +from mysensors import BaseAsyncGateway from homeassistant.components.sensor import ( SensorDeviceClass, @@ -30,13 +31,22 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from .. import mysensors -from .const import MYSENSORS_DISCOVERY, DiscoveryInfo +from .const import ( + ATTR_GATEWAY_ID, + ATTR_NODE_ID, + DOMAIN, + MYSENSORS_DISCOVERY, + MYSENSORS_GATEWAYS, + MYSENSORS_NODE_DISCOVERY, + DiscoveryInfo, + NodeDiscoveryInfo, +) from .helpers import on_unload SENSORS: dict[str, SensorEntityDescription] = { @@ -211,6 +221,14 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) + @callback + def async_node_discover(discovery_info: NodeDiscoveryInfo) -> None: + """Add battery sensor for each MySensors node.""" + gateway_id = discovery_info[ATTR_GATEWAY_ID] + node_id = discovery_info[ATTR_NODE_ID] + gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] + async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)]) + on_unload( hass, config_entry.entry_id, @@ -221,8 +239,43 @@ async def async_setup_entry( ), ) + on_unload( + hass, + config_entry.entry_id, + async_dispatcher_connect( + hass, + MYSENSORS_NODE_DISCOVERY, + async_node_discover, + ), + ) -class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): + +class MyBatterySensor(mysensors.device.MySensorNodeEntity, SensorEntity): + """Battery sensor of MySensors node.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = PERCENTAGE + _attr_force_update = True + + @property + def unique_id(self) -> str: + """Return a unique ID for use in home assistant.""" + return f"{self.gateway_id}-{self.node_id}-battery" + + @property + def name(self) -> str: + """Return the name of this entity.""" + return f"{self.node_name} Battery" + + @callback + def _async_update_callback(self) -> None: + """Update the controller with the latest battery level.""" + self._attr_native_value = self._node.battery_level + self.async_write_ha_state() + + +class MySensorsSensor(mysensors.device.MySensorsChildEntity, SensorEntity): """Representation of a MySensors Sensor child node.""" _attr_force_update = True diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 6067a98af08..b1ec1a420d2 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -58,7 +58,7 @@ async def async_setup_entry( ) -class MySensorsSwitch(MySensorsEntity, SwitchEntity): +class MySensorsSwitch(MySensorsChildEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" @property diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index e7bb7add084..68fa2a434d5 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class MySensorsText(MySensorsEntity, TextEntity): +class MySensorsText(MySensorsChildEntity, TextEntity): """Representation of the value of a MySensors Text child node.""" _attr_native_max = 25 diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index e7c0a3c5a7b..883a94ea02e 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -470,3 +470,19 @@ def text_node(gateway_nodes: dict[int, Sensor], text_node_state: dict) -> Sensor nodes = update_gateway_nodes(gateway_nodes, text_node_state) node = nodes[1] return node + + +@pytest.fixture(name="battery_sensor_state", scope="session") +def battery_sensor_state_fixture() -> dict: + """Load the battery sensor state.""" + return load_nodes_state("battery_sensor_state.json") + + +@pytest.fixture +def battery_sensor( + gateway_nodes: dict[int, Sensor], battery_sensor_state: dict +) -> Sensor: + """Load the battery sensor.""" + nodes = update_gateway_nodes(gateway_nodes, deepcopy(battery_sensor_state)) + node = nodes[1] + return node diff --git a/tests/components/mysensors/fixtures/battery_sensor_state.json b/tests/components/mysensors/fixtures/battery_sensor_state.json new file mode 100644 index 00000000000..fc89237ed97 --- /dev/null +++ b/tests/components/mysensors/fixtures/battery_sensor_state.json @@ -0,0 +1,12 @@ +{ + "1": { + "sensor_id": 1, + "children": {}, + "type": 17, + "sketch_name": "Battery Sensor", + "sketch_version": "1.0", + "battery_level": 42, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 12a47896326..17301e4b212 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -77,6 +77,25 @@ async def test_ir_transceiver( assert state.state == "new_code" +async def test_battery_entity( + hass: HomeAssistant, + battery_sensor: Sensor, + receive_message: Callable[[str], None], +) -> None: + """Test sensor with battery level reporting.""" + battery_entity_id = "sensor.battery_sensor_1_battery" + state = hass.states.get(battery_entity_id) + assert state + assert state.state == "42" + + receive_message("1;255;3;0;0;84\n") + await hass.async_block_till_done() + + state = hass.states.get(battery_entity_id) + assert state + assert state.state == "84" + + async def test_power_sensor( hass: HomeAssistant, power_sensor: Sensor, From 7a6f337b01bc8fb0930160d78a2427cc454270cc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 24 Sep 2023 15:17:45 -0600 Subject: [PATCH 747/984] Add missing SimpliSafe binary sensors (#100820) --- homeassistant/components/simplisafe/binary_sensor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 34c0ea5ea95..d9384b948ed 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -19,12 +19,18 @@ from .const import DOMAIN, LOGGER SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.CARBON_MONOXIDE, + DeviceTypes.DOORBELL, DeviceTypes.ENTRY, DeviceTypes.GLASS_BREAK, + DeviceTypes.KEYCHAIN, DeviceTypes.KEYPAD, DeviceTypes.LEAK, + DeviceTypes.LOCK, DeviceTypes.LOCK_KEYPAD, DeviceTypes.MOTION, + DeviceTypes.MOTION_V2, + DeviceTypes.PANIC_BUTTON, + DeviceTypes.REMOTE, DeviceTypes.SIREN, DeviceTypes.SMOKE, DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, @@ -37,6 +43,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.GLASS_BREAK: BinarySensorDeviceClass.SAFETY, DeviceTypes.LEAK: BinarySensorDeviceClass.MOISTURE, DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, + DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, # Although this sensor can technically apply to both smoke and carbon, we use the From 6b196023222327d83ab4793991aaf32baccae0d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 00:22:42 -0500 Subject: [PATCH 748/984] Bump aioesphomeapi to 16.0.6 (#100826) changelog: https://github.com/esphome/aioesphomeapi/compare/v16.0.5...v16.0.6 --- 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 b8bedce9556..01e11071b69 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==16.0.5", + "aioesphomeapi==16.0.6", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 462d02e9e80..4111319c2d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -232,7 +232,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.5 +aioesphomeapi==16.0.6 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2ac88724ba..c49f2b1ee57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -213,7 +213,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.5 +aioesphomeapi==16.0.6 # homeassistant.components.flo aioflo==2021.11.0 From c414e52b559664d049a63131e8a23f5403013699 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 25 Sep 2023 08:57:02 +0200 Subject: [PATCH 749/984] Change duration for timer.start service to only change running duration (#99628) * Get back duration for timer * running duration * Mods * Finish * Fix start call * remove restore idle * running duration not None * fix tests --- homeassistant/components/timer/__init__.py | 24 +++++++----- tests/components/timer/test_init.py | 45 ++-------------------- 2 files changed, 19 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 228e2071b4a..17712b6aef1 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -205,7 +205,8 @@ class Timer(collection.CollectionEntity, RestoreEntity): """Initialize a timer.""" self._config: dict = config self._state: str = STATUS_IDLE - self._duration = cv.time_period_str(config[CONF_DURATION]) + self._configured_duration = cv.time_period_str(config[CONF_DURATION]) + self._running_duration: timedelta = self._configured_duration self._remaining: timedelta | None = None self._end: datetime | None = None self._listener: Callable[[], None] | None = None @@ -248,7 +249,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): def extra_state_attributes(self): """Return the state attributes.""" attrs = { - ATTR_DURATION: _format_timedelta(self._duration), + ATTR_DURATION: _format_timedelta(self._running_duration), ATTR_EDITABLE: self.editable, } if self._end is not None: @@ -275,12 +276,12 @@ class Timer(collection.CollectionEntity, RestoreEntity): # Begin restoring state self._state = state.state - self._duration = cv.time_period(state.attributes[ATTR_DURATION]) # Nothing more to do if the timer is idle if self._state == STATUS_IDLE: return + self._running_duration = cv.time_period(state.attributes[ATTR_DURATION]) # If the timer was paused, we restore the remaining time if self._state == STATUS_PAUSED: self._remaining = cv.time_period(state.attributes[ATTR_REMAINING]) @@ -314,11 +315,11 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._state = STATUS_ACTIVE start = dt_util.utcnow().replace(microsecond=0) - # Set remaining to new value if needed + # Set remaining and running duration unless resuming or restarting if duration: - self._remaining = self._duration = duration + self._remaining = self._running_duration = duration elif not self._remaining: - self._remaining = self._duration + self._remaining = self._running_duration self._end = start + self._remaining @@ -336,9 +337,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): raise HomeAssistantError( f"Timer {self.entity_id} is not running, only active timers can be changed" ) - if self._remaining and (self._remaining + duration) > self._duration: + if self._remaining and (self._remaining + duration) > self._running_duration: raise HomeAssistantError( - f"Not possible to change timer {self.entity_id} beyond configured duration" + f"Not possible to change timer {self.entity_id} beyond duration" ) if self._remaining and (self._remaining + duration) < timedelta(): raise HomeAssistantError( @@ -377,6 +378,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._state = STATUS_IDLE self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id} ) @@ -395,6 +397,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._state = STATUS_IDLE self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_FINISHED, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, @@ -412,6 +415,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): end = self._end self._end = None self._remaining = None + self._running_duration = self._configured_duration self.hass.bus.async_fire( EVENT_TIMER_FINISHED, {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, @@ -421,6 +425,8 @@ class Timer(collection.CollectionEntity, RestoreEntity): async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" self._config = config - self._duration = cv.time_period_str(config[CONF_DURATION]) + self._configured_duration = cv.time_period_str(config[CONF_DURATION]) + if self._state == STATUS_IDLE: + self._running_duration = self._configured_duration self._restore = config.get(CONF_RESTORE, DEFAULT_RESTORE) self.async_write_ha_state() diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index eabc5e04e0b..6b6929e88ec 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -319,7 +319,7 @@ async def test_start_service(hass: HomeAssistant) -> None: with pytest.raises( HomeAssistantError, - match="Not possible to change timer timer.test1 beyond configured duration", + match="Not possible to change timer timer.test1 beyond duration", ): await hass.services.async_call( DOMAIN, @@ -370,7 +370,7 @@ async def test_start_service(hass: HomeAssistant) -> None: state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE - assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_DURATION] == "0:00:10" assert ATTR_REMAINING not in state.attributes with pytest.raises( @@ -387,7 +387,7 @@ async def test_start_service(hass: HomeAssistant) -> None: state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE - assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_DURATION] == "0:00:10" assert ATTR_REMAINING not in state.attributes @@ -844,43 +844,6 @@ async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) - assert count_start == len(hass.states.async_entity_ids()) -async def test_restore_idle(hass: HomeAssistant) -> None: - """Test entity restore logic when timer is idle.""" - utc_now = utcnow() - stored_state = StoredState( - State( - "timer.test", - STATUS_IDLE, - {ATTR_DURATION: "0:00:30"}, - ), - None, - utc_now, - ) - - data = async_get(hass) - await data.store.async_save([stored_state.as_dict()]) - await data.async_load() - - entity = Timer.from_storage( - { - CONF_ID: "test", - CONF_NAME: "test", - CONF_DURATION: "0:01:00", - CONF_RESTORE: True, - } - ) - entity.hass = hass - entity.entity_id = "timer.test" - - await entity.async_added_to_hass() - await hass.async_block_till_done() - assert entity.state == STATUS_IDLE - assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30" - assert ATTR_REMAINING not in entity.extra_state_attributes - assert ATTR_FINISHES_AT not in entity.extra_state_attributes - assert entity.extra_state_attributes[ATTR_RESTORE] - - @pytest.mark.freeze_time("2023-06-05 17:47:50") async def test_restore_paused(hass: HomeAssistant) -> None: """Test entity restore logic when timer is paused.""" @@ -1007,7 +970,7 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert entity.state == STATUS_IDLE - assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30" + assert entity.extra_state_attributes[ATTR_DURATION] == "0:01:00" assert ATTR_REMAINING not in entity.extra_state_attributes assert ATTR_FINISHES_AT not in entity.extra_state_attributes assert entity.extra_state_attributes[ATTR_RESTORE] From 8d50be379c29bc6c4687a36854c6e45a9bbcb79d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 25 Sep 2023 08:59:15 +0200 Subject: [PATCH 750/984] Create repairs in Workday if country or province is wrong (#98753) * Repairs workday * fix if not province exist * Tests repairs * Add tests * Finalize tests * Fix feedback * simplify * Less translations --- homeassistant/components/workday/__init__.py | 25 +- homeassistant/components/workday/repairs.py | 124 ++++++ homeassistant/components/workday/strings.json | 43 ++ tests/components/workday/test_repairs.py | 399 ++++++++++++++++++ 4 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/workday/repairs.py create mode 100644 tests/components/workday/test_repairs.py diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index c3bf7f2efd5..558e0aa9ecf 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -6,8 +6,9 @@ from holidays import list_supported_countries from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import CONF_COUNTRY, CONF_PROVINCE, PLATFORMS +from .const import CONF_COUNTRY, CONF_PROVINCE, DOMAIN, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,9 +18,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: province: str | None = entry.options.get(CONF_PROVINCE) if country and country not in list_supported_countries(): + async_create_issue( + hass, + DOMAIN, + "bad_country", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.ERROR, + translation_key="bad_country", + translation_placeholders={"title": entry.title}, + data={"entry_id": entry.entry_id, "country": None}, + ) raise ConfigEntryError(f"Selected country {country} is not valid") if country and province and province not in list_supported_countries()[country]: + async_create_issue( + hass, + DOMAIN, + "bad_province", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.ERROR, + translation_key="bad_province", + translation_placeholders={CONF_COUNTRY: country, "title": entry.title}, + data={"entry_id": entry.entry_id, "country": country}, + ) raise ConfigEntryError( f"Selected province {province} for country {country} is not valid" ) diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py new file mode 100644 index 00000000000..ff643ecc2cb --- /dev/null +++ b/homeassistant/components/workday/repairs.py @@ -0,0 +1,124 @@ +"""Repairs platform for the Workday integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .config_flow import NONE_SENTINEL +from .const import CONF_PROVINCE + + +class CountryFixFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry, country: str | None) -> None: + """Create flow.""" + self.entry = entry + self.country: str | None = country + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + if self.country: + return await self.async_step_province() + return await self.async_step_country() + + async def async_step_country( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the country step of a fix flow.""" + if user_input is not None: + all_countries = list_supported_countries() + if not all_countries[user_input[CONF_COUNTRY]]: + options = dict(self.entry.options) + new_options = {**options, **user_input, CONF_PROVINCE: None} + self.hass.config_entries.async_update_entry( + self.entry, options=new_options + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + self.country = user_input[CONF_COUNTRY] + return await self.async_step_province() + + return self.async_show_form( + step_id="country", + data_schema=vol.Schema( + { + vol.Required(CONF_COUNTRY): SelectSelector( + SelectSelectorConfig( + options=sorted(list_supported_countries()), + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + description_placeholders={"title": self.entry.title}, + ) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the province step of a fix flow.""" + if user_input and user_input.get(CONF_PROVINCE): + if user_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: + user_input[CONF_PROVINCE] = None + options = dict(self.entry.options) + new_options = {**options, **user_input, CONF_COUNTRY: self.country} + self.hass.config_entries.async_update_entry(self.entry, options=new_options) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + assert self.country + country_provinces = list_supported_countries()[self.country] + return self.async_show_form( + step_id="province", + data_schema=vol.Schema( + { + vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + SelectSelectorConfig( + options=[NONE_SENTINEL, *country_provinces], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PROVINCE, + ) + ), + } + ), + description_placeholders={ + CONF_COUNTRY: self.country, + "title": self.entry.title, + }, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, Any] | None, +) -> RepairsFlow: + """Create flow.""" + entry = None + if data and (entry_id := data.get("entry_id")): + entry_id = cast(str, entry_id) + entry = hass.config_entries.async_get_entry(entry_id) + + if data and entry: + # Country or province does not exist + return CountryFixFlow(entry, data.get("country")) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index b4bad4796bc..718f99d7c8a 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -88,5 +88,48 @@ "holiday": "Holidays" } } + }, + "issues": { + "bad_country": { + "title": "Configured Country for {title} does not exist", + "fix_flow": { + "step": { + "country": { + "title": "Select country for {title}", + "description": "Select a country to use for your Workday sensor.", + "data": { + "country": "[%key:component::workday::config::step::user::data::country%]" + } + }, + "province": { + "title": "Select province for {title}", + "description": "Select a province in country {country} to use for your Workday sensor.", + "data": { + "province": "[%key:component::workday::config::step::options::data::province%]" + }, + "data_description": { + "province": "State, Territory, Province, Region of Country" + } + } + } + } + }, + "bad_province": { + "title": "Configured province in country {country} for {title} does not exist", + "fix_flow": { + "step": { + "province": { + "title": "[%key:component::workday::issues::bad_country::fix_flow::step::province::title%]", + "description": "[%key:component::workday::issues::bad_country::fix_flow::step::province::description%]", + "data": { + "province": "[%key:component::workday::config::step::options::data::province%]" + }, + "data_description": { + "province": "[%key:component::workday::issues::bad_country::fix_flow::step::province::data_description::province%]" + } + } + } + } + } } } diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py new file mode 100644 index 00000000000..38b2142dfb7 --- /dev/null +++ b/tests/components/workday/test_repairs.py @@ -0,0 +1,399 @@ +"""Test repairs for unifiprotect.""" +from __future__ import annotations + +from http import HTTPStatus + +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.components.workday.const import DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import async_create_issue +from homeassistant.setup import async_setup_component + +from . import ( + TEST_CONFIG_INCORRECT_COUNTRY, + TEST_CONFIG_INCORRECT_PROVINCE, + init_integration, +) + +from tests.common import ANY +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +async def test_bad_country( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "DE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "HB"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_country_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country with no province.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "DE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "none"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_country_no_province( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad country.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"title": entry.title} + assert data["step_id"] == "country" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"country": "SE"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_country": + issue = i + assert not issue + + +async def test_bad_province( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_province"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "DE", + "title": entry.title, + } + assert data["step_id"] == "province" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "BW"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert not issue + + +async def test_bad_province_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert not state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_province"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "DE", + "title": entry.title, + } + assert data["step_id"] == "province" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json={"province": "none"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_province": + issue = i + assert not issue + + +async def test_other_fixable_issues( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + + issue = { + "breaks_in_ha_version": "2022.9.0dev0", + "domain": DOMAIN, + "issue_id": "issue_1", + "is_fixable": True, + "learn_more_url": "", + "severity": "error", + "translation_key": "issue_1", + } + async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + breaks_in_ha_version=issue["breaks_in_ha_version"], + is_fixable=issue["is_fixable"], + is_persistent=False, + learn_more_url=None, + severity=issue["severity"], + translation_key=issue["translation_key"], + ) + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + results = msg["result"]["issues"] + assert { + "breaks_in_ha_version": "2022.9.0dev0", + "created": ANY, + "dismissed_version": None, + "domain": "workday", + "is_fixable": True, + "issue_domain": None, + "issue_id": "issue_1", + "learn_more_url": None, + "severity": "error", + "translation_key": "issue_1", + "translation_placeholders": None, + "ignored": False, + } in results + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "issue_1"}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() From 2a443648fc5da6dbdcce5713c21ad63b70ad989d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Sep 2023 11:01:12 +0200 Subject: [PATCH 751/984] Bump actions/checkout from 4.0.0 to 4.1.0 (#100836) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 ++++++------ .github/workflows/ci.yaml | 28 ++++++++++++++-------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 191b510c0ff..20d158ed676 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 with: fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -252,7 +252,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set build additional args run: | @@ -289,7 +289,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -327,7 +327,7 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Install Cosign uses: sigstore/cosign-installer@v3.1.2 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2ac6773b6e9..2675a17e421 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -89,7 +89,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -222,7 +222,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -267,7 +267,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -335,7 +335,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -384,7 +384,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -478,7 +478,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -546,7 +546,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -578,7 +578,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -611,7 +611,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -655,7 +655,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -737,7 +737,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -889,7 +889,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -1013,7 +1013,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -1108,7 +1108,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index a98c4d99734..84d7fc03e43 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0bf89a8e050..6c3022b194b 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Get information id: info @@ -84,7 +84,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -122,7 +122,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.0.0 + uses: actions/checkout@v4.1.0 - name: Download env_file uses: actions/download-artifact@v3 From 19854ded16231a94355a70eb01b6b1d77989f1f5 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 25 Sep 2023 12:52:51 +0200 Subject: [PATCH 752/984] Add duotecno climate (#99333) * Add duotecno climate * Add climate to .coveragerc * Update homeassistant/components/duotecno/climate.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/duotecno/climate.py Co-authored-by: Joost Lekkerkerker * more comments * more comments * more comments * more comments * fix typo * Add translation key --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + homeassistant/components/duotecno/__init__.py | 7 +- homeassistant/components/duotecno/climate.py | 92 +++++++++++++++++++ homeassistant/components/duotecno/const.py | 3 +- .../components/duotecno/strings.json | 16 ++++ 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/duotecno/climate.py diff --git a/.coveragerc b/.coveragerc index 752ea5ca7bc..c72c570feab 100644 --- a/.coveragerc +++ b/.coveragerc @@ -246,6 +246,7 @@ omit = homeassistant/components/duotecno/switch.py homeassistant/components/duotecno/cover.py homeassistant/components/duotecno/light.py + homeassistant/components/duotecno/climate.py homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index 4c8060b468d..bc7d519aa9c 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -11,7 +11,12 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.COVER, Platform.LIGHT] +PLATFORMS: list[Platform] = [ + Platform.SWITCH, + Platform.COVER, + Platform.LIGHT, + Platform.CLIMATE, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py new file mode 100644 index 00000000000..e7dfa53e53c --- /dev/null +++ b/homeassistant/components/duotecno/climate.py @@ -0,0 +1,92 @@ +"""Support for Duotecno climate devices.""" +from typing import Any, Final + +from duotecno.unit import SensUnit + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +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 .const import DOMAIN +from .entity import DuotecnoEntity, api_call + +HVACMODE: Final = { + 0: HVACMode.OFF, + 1: HVACMode.HEAT, + 2: HVACMode.COOL, +} +HVACMODE_REVERSE: Final = {value: key for key, value in HVACMODE.items()} + +PRESETMODES: Final = { + "sun": 0, + "half_sun": 1, + "moon": 2, + "half_moon": 3, +} +PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Duotecno climate based on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoClimate(channel) for channel in cntrl.get_units(["SensUnit"]) + ) + + +class DuotecnoClimate(DuotecnoEntity, ClimateEntity): + """Representation of a Duotecno climate entity.""" + + _unit: SensUnit + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = list(HVACMODE_REVERSE) + _attr_preset_modes = list(PRESETMODES) + _attr_translation_key = "duotecno" + + @property + def current_temperature(self) -> int | None: + """Get the current temperature.""" + return self._unit.get_cur_temp() + + @property + def target_temperature(self) -> float | None: + """Get the target temperature.""" + return self._unit.get_target_temp() + + @property + def hvac_mode(self) -> HVACMode: + """Get the current hvac_mode.""" + return HVACMODE[self._unit.get_state()] + + @property + def preset_mode(self) -> str: + """Get the preset mode.""" + return PRESETMODES_REVERSE[self._unit.get_preset()] + + @api_call + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self._unit.set_temp(temp) + + @api_call + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + await self._unit.set_preset(PRESETMODES[preset_mode]) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Duotecno does not support setting this, we can only display it.""" diff --git a/homeassistant/components/duotecno/const.py b/homeassistant/components/duotecno/const.py index 114867b8d95..6bffe2358e1 100644 --- a/homeassistant/components/duotecno/const.py +++ b/homeassistant/components/duotecno/const.py @@ -1,3 +1,4 @@ """Constants for the duotecno integration.""" +from typing import Final -DOMAIN = "duotecno" +DOMAIN: Final = "duotecno" diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 379291eb626..a00647993a8 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -14,5 +14,21 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "climate": { + "duotecno": { + "state_attributes": { + "preset_mode": { + "state": { + "sun": "Sun", + "half_sun": "Half sun", + "moon": "Moon", + "half_moon": "Half moon" + } + } + } + } + } } } From 2ae07096d252da7e51500d064bcc7fa312c1eb14 Mon Sep 17 00:00:00 2001 From: Alex Yao <33379584+alexyao2015@users.noreply.github.com> Date: Mon, 25 Sep 2023 06:09:16 -0500 Subject: [PATCH 753/984] Address late review on Life360 button (#100740) --- homeassistant/components/life360/button.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/life360/button.py b/homeassistant/components/life360/button.py index 6b460c8531c..07ef4d06ed9 100644 --- a/homeassistant/components/life360/button.py +++ b/homeassistant/components/life360/button.py @@ -19,12 +19,10 @@ async def async_setup_entry( coordinator: Life360DataUpdateCoordinator = hass.data[DOMAIN].coordinators[ config_entry.entry_id ] - for member_id, member in coordinator.data.members.items(): - async_add_entities( - [ - Life360UpdateLocationButton(coordinator, member.circle_id, member_id), - ] - ) + async_add_entities( + Life360UpdateLocationButton(coordinator, member.circle_id, member_id) + for member_id, member in coordinator.data.members.items() + ) class Life360UpdateLocationButton( From 77007ef0910f71e1aebc59a4d01ffd310d74f36e Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Mon, 25 Sep 2023 04:26:26 -0700 Subject: [PATCH 754/984] Explicitly define ScreenLogic entity descriptions (#100173) --- .../components/screenlogic/__init__.py | 10 +- .../components/screenlogic/binary_sensor.py | 315 ++++--- .../components/screenlogic/climate.py | 7 +- homeassistant/components/screenlogic/data.py | 186 +--- .../components/screenlogic/entity.py | 25 +- homeassistant/components/screenlogic/light.py | 9 +- .../components/screenlogic/number.py | 180 ++-- .../components/screenlogic/sensor.py | 489 +++++----- .../components/screenlogic/switch.py | 9 +- homeassistant/components/screenlogic/util.py | 30 +- tests/components/screenlogic/__init__.py | 9 + .../fixtures/data_full_no_gpm.json | 784 ++++++++++++++++ .../fixtures/data_full_no_salt_ppm.json | 859 ++++++++++++++++++ .../data_missing_values_chem_chlor.json | 849 +++++++++++++++++ tests/components/screenlogic/test_data.py | 21 - tests/components/screenlogic/test_init.py | 43 + 16 files changed, 3144 insertions(+), 681 deletions(-) create mode 100644 tests/components/screenlogic/fixtures/data_full_no_gpm.json create mode 100644 tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json create mode 100644 tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 298e1c1ca00..7276ec28323 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -129,9 +129,12 @@ async def _async_migrate_entries( new_key, ) continue - assert device is not None and ( - device != "pump" or (device == "pump" and source_index is not None) - ) + if device == "pump" and source_index is None: + _LOGGER.debug( + "Unable to parse 'source_index' from existing unique_id for pump entity '%s'", + source_key, + ) + continue new_unique_id = ( f"{source_mac}_{generate_unique_id(device, source_index, new_key)}" ) @@ -152,7 +155,6 @@ async def _async_migrate_entries( updates["new_unique_id"] = new_unique_id if (old_name := migrations.get("old_name")) is not None: - assert old_name new_name = migrations["new_name"] if (s_old_name := slugify(old_name)) in entry.entity_id: new_entity_id = entry.entity_id.replace(s_old_name, slugify(new_name)) diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 337d308d8d9..9192458dde4 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,9 +1,12 @@ """Support for a ScreenLogic Binary Sensor.""" +from copy import copy from dataclasses import dataclass import logging -from screenlogicpy.const.common import DEVICE_TYPE, ON_OFF +from screenlogicpy.const.common import ON_OFF from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.binary_sensor import ( DOMAIN, @@ -12,85 +15,157 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) 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 as SL_DOMAIN, ScreenLogicDataPath +from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .data import ( - DEVICE_INCLUSION_RULES, - DEVICE_SUBSCRIPTION, - SupportedValueParameters, - build_base_entity_description, - iterate_expand_group_wildcard, - preprocess_supported_values, -) from .entity import ( ScreenlogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, ) -from .util import cleanup_excluded_entity, generate_unique_id +from .util import cleanup_excluded_entity _LOGGER = logging.getLogger(__name__) @dataclass -class SupportedBinarySensorValueParameters(SupportedValueParameters): - """Supported predefined data for a ScreenLogic binary sensor entity.""" - - device_class: BinarySensorDeviceClass | None = None +class ScreenLogicBinarySensorDescription( + BinarySensorEntityDescription, ScreenLogicEntityDescription +): + """A class that describes ScreenLogic binary sensor eneites.""" -SUPPORTED_DATA: list[ - tuple[ScreenLogicDataPath, SupportedValueParameters] -] = preprocess_supported_values( - { - DEVICE.CONTROLLER: { - GROUP.SENSOR: { - VALUE.ACTIVE_ALERT: SupportedBinarySensorValueParameters(), - VALUE.CLEANER_DELAY: SupportedBinarySensorValueParameters(), - VALUE.FREEZE_MODE: SupportedBinarySensorValueParameters(), - VALUE.POOL_DELAY: SupportedBinarySensorValueParameters(), - VALUE.SPA_DELAY: SupportedBinarySensorValueParameters(), - }, - }, - DEVICE.PUMP: { - "*": { - VALUE.STATE: SupportedBinarySensorValueParameters(), - }, - }, - DEVICE.INTELLICHEM: { - GROUP.ALARM: { - VALUE.FLOW_ALARM: SupportedBinarySensorValueParameters(), - VALUE.ORP_HIGH_ALARM: SupportedBinarySensorValueParameters(), - VALUE.ORP_LOW_ALARM: SupportedBinarySensorValueParameters(), - VALUE.ORP_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), - VALUE.PH_HIGH_ALARM: SupportedBinarySensorValueParameters(), - VALUE.PH_LOW_ALARM: SupportedBinarySensorValueParameters(), - VALUE.PH_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), - VALUE.PROBE_FAULT_ALARM: SupportedBinarySensorValueParameters(), - }, - GROUP.ALERT: { - VALUE.ORP_LIMIT: SupportedBinarySensorValueParameters(), - VALUE.PH_LIMIT: SupportedBinarySensorValueParameters(), - VALUE.PH_LOCKOUT: SupportedBinarySensorValueParameters(), - }, - GROUP.WATER_BALANCE: { - VALUE.CORROSIVE: SupportedBinarySensorValueParameters(), - VALUE.SCALING: SupportedBinarySensorValueParameters(), - }, - }, - DEVICE.SCG: { - GROUP.SENSOR: { - VALUE.STATE: SupportedBinarySensorValueParameters(), - }, - }, - } -) +@dataclass +class ScreenLogicPushBinarySensorDescription( + ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogicPushBinarySensor.""" -SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {DEVICE_TYPE.ALARM: BinarySensorDeviceClass.PROBLEM} + +SUPPORTED_CORE_SENSORS = [ + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.ACTIVE_ALERT, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.CLEANER_DELAY, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.FREEZE_MODE, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.POOL_DELAY, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.SPA_DELAY, + ), +] + +SUPPORTED_PUMP_SENSORS = [ + ScreenLogicBinarySensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.STATE, + ) +] + +SUPPORTED_INTELLICHEM_SENSORS = [ + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.FLOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_HIGH_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_LOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.ORP_SUPPLY_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_HIGH_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_LOW_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PH_SUPPLY_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALARM), + key=VALUE.PROBE_FAULT_ALARM, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.ORP_LIMIT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.PH_LIMIT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.ALERT), + key=VALUE.PH_LOCKOUT, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE), + key=VALUE.CORROSIVE, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + ScreenLogicPushBinarySensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.WATER_BALANCE), + key=VALUE.SCALING, + device_class=BinarySensorDeviceClass.PROBLEM, + ), +] + +SUPPORTED_SCG_SENSORS = [ + ScreenLogicBinarySensorDescription( + data_root=(DEVICE.SCG, GROUP.SENSOR), + key=VALUE.STATE, + ) +] async def async_setup_entry( @@ -104,72 +179,65 @@ async def async_setup_entry( config_entry.entry_id ] gateway = coordinator.gateway - data_path: ScreenLogicDataPath - value_params: SupportedBinarySensorValueParameters - for data_path, value_params in iterate_expand_group_wildcard( - gateway, SUPPORTED_DATA - ): - entity_key = generate_unique_id(*data_path) - - device = data_path[0] - - if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( - gateway, data_path - ): - cleanup_excluded_entity(coordinator, DOMAIN, entity_key) - continue - - try: - value_data = gateway.get_data(*data_path, strict=True) - except KeyError: - _LOGGER.debug("Failed to find %s", data_path) - continue - - entity_description_kwargs = { - **build_base_entity_description( - gateway, entity_key, data_path, value_data, value_params - ), - "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( - value_data.get(ATTR.DEVICE_TYPE) - ), - } + for core_sensor_description in SUPPORTED_CORE_SENSORS: if ( - sub_code := ( - value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + gateway.get_data( + *core_sensor_description.data_root, core_sensor_description.key ) - ) is not None: + is not None + ): entities.append( - ScreenLogicPushBinarySensor( - coordinator, - ScreenLogicPushBinarySensorDescription( - subscription_code=sub_code, **entity_description_kwargs - ), + ScreenLogicPushBinarySensor(coordinator, core_sensor_description) + ) + + for p_index, p_data in gateway.get_data(DEVICE.PUMP).items(): + if not p_data or not p_data.get(VALUE.DATA): + continue + for proto_pump_sensor_description in SUPPORTED_PUMP_SENSORS: + entities.append( + ScreenLogicPumpBinarySensor( + coordinator, copy(proto_pump_sensor_description), p_index ) ) - else: + + chem_sensor_description: ScreenLogicPushBinarySensorDescription + for chem_sensor_description in SUPPORTED_INTELLICHEM_SENSORS: + chem_sensor_data_path = ( + *chem_sensor_description.data_root, + chem_sensor_description.key, + ) + if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) + continue + if gateway.get_data(*chem_sensor_data_path): entities.append( - ScreenLogicBinarySensor( - coordinator, - ScreenLogicBinarySensorDescription(**entity_description_kwargs), - ) + ScreenLogicPushBinarySensor(coordinator, chem_sensor_description) + ) + + scg_sensor_description: ScreenLogicBinarySensorDescription + for scg_sensor_description in SUPPORTED_SCG_SENSORS: + scg_sensor_data_path = ( + *scg_sensor_description.data_root, + scg_sensor_description.key, + ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) + continue + if gateway.get_data(*scg_sensor_data_path): + entities.append( + ScreenLogicBinarySensor(coordinator, scg_sensor_description) ) async_add_entities(entities) -@dataclass -class ScreenLogicBinarySensorDescription( - BinarySensorEntityDescription, ScreenLogicEntityDescription -): - """A class that describes ScreenLogic binary sensor eneites.""" - - class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): - """Base class for all ScreenLogic binary sensor entities.""" + """Representation of a ScreenLogic binary sensor entity.""" entity_description: ScreenLogicBinarySensorDescription _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC @property def is_on(self) -> bool: @@ -177,14 +245,21 @@ class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): return self.entity_data[ATTR.VALUE] == ON_OFF.ON -@dataclass -class ScreenLogicPushBinarySensorDescription( - ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription -): - """Describes a ScreenLogicPushBinarySensor.""" - - class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor): - """Representation of a basic ScreenLogic sensor entity.""" + """Representation of a ScreenLogic push binary sensor entity.""" entity_description: ScreenLogicPushBinarySensorDescription + + +class ScreenLogicPumpBinarySensor(ScreenLogicBinarySensor): + """Representation of a ScreenLogic binary sensor entity for pump data.""" + + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicBinarySensorDescription, + pump_index: int, + ) -> None: + """Initialize of the entity.""" + entity_description.data_root = (DEVICE.PUMP, pump_index) + super().__init__(coordinator, entity_description) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 889c8617274..1d3f366a498 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -53,16 +53,14 @@ async def async_setup_entry( gateway = coordinator.gateway - for body_index, body_data in gateway.get_data(DEVICE.BODY).items(): - body_path = (DEVICE.BODY, body_index) + for body_index in gateway.get_data(DEVICE.BODY): entities.append( ScreenLogicClimate( coordinator, ScreenLogicClimateDescription( subscription_code=CODE.STATUS_CHANGED, - data_path=body_path, + data_root=(DEVICE.BODY,), key=body_index, - name=body_data[VALUE.HEAT_STATE][ATTR.NAME], ), ) ) @@ -99,6 +97,7 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] + self._attr_name = self.entity_data[VALUE.HEAT_STATE][ATTR.NAME] self._last_preset = None @property diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py index 5679b7e4dc9..719cebc1ef6 100644 --- a/homeassistant/components/screenlogic/data.py +++ b/homeassistant/components/screenlogic/data.py @@ -1,189 +1,5 @@ """Support for configurable supported data values for the ScreenLogic integration.""" -from collections.abc import Callable, Generator -from dataclasses import dataclass -from enum import StrEnum -from typing import Any - -from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.data import ATTR, DEVICE, VALUE -from screenlogicpy.const.msg import CODE -from screenlogicpy.device_const.system import EQUIPMENT_FLAG - -from homeassistant.const import EntityCategory - -from .const import SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath - - -class PathPart(StrEnum): - """Placeholders for local data_path values.""" - - DEVICE = "!device" - KEY = "!key" - INDEX = "!index" - VALUE = "!sensor" - - -ScreenLogicDataPathTemplate = tuple[PathPart | str | int, ...] - - -class ScreenLogicRule: - """Represents a base default passing rule.""" - - def __init__( - self, test: Callable[..., bool] = lambda gateway, data_path: True - ) -> None: - """Initialize a ScreenLogic rule.""" - self._test = test - - def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: - """Method to check the rule.""" - return self._test(gateway, data_path) - - -class ScreenLogicDataRule(ScreenLogicRule): - """Represents a data rule.""" - - def __init__( - self, test: Callable[..., bool], test_path_template: tuple[PathPart, ...] - ) -> None: - """Initialize a ScreenLogic data rule.""" - self._test_path_template = test_path_template - super().__init__(test) - - def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: - """Check the rule against the gateway's data.""" - test_path = realize_path_template(self._test_path_template, data_path) - return self._test(gateway.get_data(*test_path)) - - -class ScreenLogicEquipmentRule(ScreenLogicRule): - """Represents an equipment flag rule.""" - - def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: - """Check the rule against the gateway's equipment flags.""" - return self._test(gateway.equipment_flags) - - -@dataclass -class SupportedValueParameters: - """Base supported values for ScreenLogic Entities.""" - - enabled: ScreenLogicRule = ScreenLogicRule() - included: ScreenLogicRule = ScreenLogicRule() - subscription_code: int | None = None - entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC - - -SupportedValueDescriptions = dict[str, SupportedValueParameters] - -SupportedGroupDescriptions = dict[int | str, SupportedValueDescriptions] - -SupportedDeviceDescriptions = dict[str, SupportedGroupDescriptions] - - -DEVICE_INCLUSION_RULES = { - DEVICE.PUMP: ScreenLogicDataRule( - lambda pump_data: pump_data[VALUE.DATA] != 0, - (PathPart.DEVICE, PathPart.INDEX), - ), - DEVICE.INTELLICHEM: ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags, - ), - DEVICE.SCG: ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.CHLORINATOR in flags, - ), -} - -DEVICE_SUBSCRIPTION = { - DEVICE.CONTROLLER: CODE.STATUS_CHANGED, - DEVICE.INTELLICHEM: CODE.CHEMISTRY_CHANGED, -} - - -# not run-time -def get_ha_unit(entity_data: dict) -> StrEnum | str | None: - """Return a Home Assistant unit of measurement from a UNIT.""" - sl_unit = entity_data.get(ATTR.UNIT) - return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) - - -# partial run-time -def realize_path_template( - template_path: ScreenLogicDataPathTemplate, data_path: ScreenLogicDataPath -) -> ScreenLogicDataPath: - """Create a new data path using a template and an existing data path. - - Construct new ScreenLogicDataPath from data_path using - template_path to specify values from data_path. - """ - if not data_path or len(data_path) < 3: - raise KeyError( - f"Missing or invalid required parameter: 'data_path' for template path '{template_path}'" - ) - device, group, data_key = data_path - realized_path: list[str | int] = [] - for part in template_path: - match part: - case PathPart.DEVICE: - realized_path.append(device) - case PathPart.INDEX | PathPart.KEY: - realized_path.append(group) - case PathPart.VALUE: - realized_path.append(data_key) - case _: - realized_path.append(part) - - return tuple(realized_path) - - -def preprocess_supported_values( - supported_devices: SupportedDeviceDescriptions, -) -> list[tuple[ScreenLogicDataPath, Any]]: - """Expand config dict into list of ScreenLogicDataPaths and settings.""" - processed: list[tuple[ScreenLogicDataPath, Any]] = [] - for device, device_groups in supported_devices.items(): - for group, group_values in device_groups.items(): - for value_key, value_params in group_values.items(): - value_data_path = (device, group, value_key) - processed.append((value_data_path, value_params)) - return processed - - -def iterate_expand_group_wildcard( - gateway: ScreenLogicGateway, - preprocessed_data: list[tuple[ScreenLogicDataPath, Any]], -) -> Generator[tuple[ScreenLogicDataPath, Any], None, None]: - """Iterate and expand any group wildcards to all available entries in gateway.""" - for data_path, value_params in preprocessed_data: - device, group, value_key = data_path - if group == "*": - for index in gateway.get_data(device): - yield ((device, index, value_key), value_params) - else: - yield (data_path, value_params) - - -def build_base_entity_description( - gateway: ScreenLogicGateway, - entity_key: str, - data_path: ScreenLogicDataPath, - value_data: dict, - value_params: SupportedValueParameters, -) -> dict: - """Build base entity description. - - Returns a dict of entity description key value pairs common to all entities. - """ - return { - "data_path": data_path, - "key": entity_key, - "entity_category": value_params.entity_category, - "entity_registry_enabled_default": value_params.enabled.test( - gateway, data_path - ), - "name": value_data.get(ATTR.NAME), - } - +from screenlogicpy.const.data import DEVICE, VALUE ENTITY_MIGRATIONS = { "chem_alarm": { diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index a29aaa9125b..3b45aa699d3 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,4 +1,5 @@ """Base ScreenLogicEntity definitions.""" +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime import logging @@ -18,15 +19,16 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ScreenLogicDataPath from .coordinator import ScreenlogicDataUpdateCoordinator +from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @dataclass class ScreenLogicEntityRequiredKeyMixin: - """Mixin for required ScreenLogic entity key.""" + """Mixin for required ScreenLogic entity data_path.""" - data_path: ScreenLogicDataPath + data_root: ScreenLogicDataPath @dataclass @@ -35,6 +37,8 @@ class ScreenLogicEntityDescription( ): """Base class for a ScreenLogic entity description.""" + enabled_lambda: Callable[..., bool] | None = None + class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" @@ -50,10 +54,11 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Initialize of the entity.""" super().__init__(coordinator) self.entity_description = entity_description - self._data_path = self.entity_description.data_path - self._data_key = self._data_path[-1] - self._attr_unique_id = f"{self.mac}_{self.entity_description.key}" + self._data_key = self.entity_description.key + self._data_path = (*self.entity_description.data_root, self._data_key) mac = self.mac + self._attr_unique_id = f"{mac}_{generate_unique_id(*self._data_path)}" + self._attr_name = self.entity_data[ATTR.NAME] assert mac is not None self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, @@ -88,9 +93,10 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): @property def entity_data(self) -> dict: """Shortcut to the data for this entity.""" - if (data := self.gateway.get_data(*self._data_path)) is None: - raise KeyError(f"Data not found: {self._data_path}") - return data + try: + return self.gateway.get_data(*self._data_path, strict=True) + except KeyError as ke: + raise HomeAssistantError(f"Data not found: {self._data_path}") from ke @dataclass @@ -120,6 +126,7 @@ class ScreenLogicPushEntity(ScreenlogicEntity): ) -> None: """Initialize of the entity.""" super().__init__(coordinator, entity_description) + self._subscription_code = entity_description.subscription_code self._last_update_success = True @callback @@ -134,7 +141,7 @@ class ScreenLogicPushEntity(ScreenlogicEntity): self.async_on_remove( await self.gateway.async_subscribe_client( self._async_data_updated, - self.entity_description.subscription_code, + self._subscription_code, ) ) diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 3875e34fbaa..80499f7790a 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -34,7 +34,11 @@ async def async_setup_entry( ] gateway = coordinator.gateway for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): - if circuit_data[ATTR.FUNCTION] not in LIGHT_CIRCUIT_FUNCTIONS: + if ( + not circuit_data + or ((circuit_function := circuit_data.get(ATTR.FUNCTION)) is None) + or circuit_function not in LIGHT_CIRCUIT_FUNCTIONS + ): continue circuit_name = circuit_data[ATTR.NAME] circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) @@ -43,9 +47,8 @@ async def async_setup_entry( coordinator, ScreenLogicLightDescription( subscription_code=CODE.STATUS_CHANGED, - data_path=(DEVICE.CIRCUIT, circuit_index), + data_root=(DEVICE.CIRCUIT,), key=circuit_index, - name=circuit_name, entity_registry_enabled_default=( circuit_name not in GENERIC_CIRCUIT_NAMES and circuit_interface != INTERFACE.DONT_SHOW diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index 22805ffc3c1..d3ed25f5570 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -4,10 +4,10 @@ from dataclasses import dataclass import logging from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.number import ( DOMAIN, - NumberDeviceClass, NumberEntity, NumberEntityDescription, ) @@ -16,20 +16,10 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .data import ( - DEVICE_INCLUSION_RULES, - PathPart, - SupportedValueParameters, - build_base_entity_description, - get_ha_unit, - iterate_expand_group_wildcard, - preprocess_supported_values, - realize_path_template, -) from .entity import ScreenlogicEntity, ScreenLogicEntityDescription -from .util import cleanup_excluded_entity, generate_unique_id +from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) @@ -37,47 +27,44 @@ PARALLEL_UPDATES = 1 @dataclass -class SupportedNumberValueParametersMixin: - """Mixin for supported predefined data for a ScreenLogic number entity.""" +class ScreenLogicNumberRequiredMixin: + """Describes a required mixin for a ScreenLogic number entity.""" - set_value_config: tuple[str, tuple[tuple[PathPart | str | int, ...], ...]] - device_class: NumberDeviceClass | None = None + set_value_name: str + set_value_args: tuple[tuple[str | int, ...], ...] @dataclass -class SupportedNumberValueParameters( - SupportedValueParameters, SupportedNumberValueParametersMixin +class ScreenLogicNumberDescription( + NumberEntityDescription, + ScreenLogicEntityDescription, + ScreenLogicNumberRequiredMixin, ): - """Supported predefined data for a ScreenLogic number entity.""" + """Describes a ScreenLogic number entity.""" -SET_SCG_CONFIG_FUNC_DATA = ( - "async_set_scg_config", - ( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), +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, ), -) - - -SUPPORTED_DATA: list[ - tuple[ScreenLogicDataPath, SupportedValueParameters] -] = preprocess_supported_values( - { - DEVICE.SCG: { - GROUP.CONFIGURATION: { - VALUE.POOL_SETPOINT: SupportedNumberValueParameters( - entity_category=EntityCategory.CONFIG, - set_value_config=SET_SCG_CONFIG_FUNC_DATA, - ), - VALUE.SPA_SETPOINT: SupportedNumberValueParameters( - entity_category=EntityCategory.CONFIG, - set_value_config=SET_SCG_CONFIG_FUNC_DATA, - ), - } - } - } -) + 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, + ), +] async def async_setup_entry( @@ -91,70 +78,21 @@ async def async_setup_entry( config_entry.entry_id ] gateway = coordinator.gateway - data_path: ScreenLogicDataPath - value_params: SupportedNumberValueParameters - for data_path, value_params in iterate_expand_group_wildcard( - gateway, SUPPORTED_DATA - ): - entity_key = generate_unique_id(*data_path) - device = data_path[0] - - if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( - gateway, data_path - ): - cleanup_excluded_entity(coordinator, DOMAIN, entity_key) - continue - - try: - value_data = gateway.get_data(*data_path, strict=True) - except KeyError: - _LOGGER.debug("Failed to find %s", data_path) - continue - - set_value_str, set_value_params = value_params.set_value_config - set_value_func = getattr(gateway, set_value_str) - - entity_description_kwargs = { - **build_base_entity_description( - gateway, entity_key, data_path, value_data, value_params - ), - "device_class": value_params.device_class, - "native_unit_of_measurement": get_ha_unit(value_data), - "native_max_value": value_data.get(ATTR.MAX_SETPOINT), - "native_min_value": value_data.get(ATTR.MIN_SETPOINT), - "native_step": value_data.get(ATTR.STEP), - "set_value": set_value_func, - "set_value_params": set_value_params, - } - - entities.append( - ScreenLogicNumber( - coordinator, - ScreenLogicNumberDescription(**entity_description_kwargs), - ) + for scg_number_description in SUPPORTED_SCG_NUMBERS: + scg_number_data_path = ( + *scg_number_description.data_root, + scg_number_description.key, ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_number_data_path) + continue + if gateway.get_data(*scg_number_data_path): + entities.append(ScreenLogicNumber(coordinator, scg_number_description)) async_add_entities(entities) -@dataclass -class ScreenLogicNumberRequiredMixin: - """Describes a required mixin for a ScreenLogic number entity.""" - - set_value: Callable[..., bool] - set_value_params: tuple[tuple[str | int, ...], ...] - - -@dataclass -class ScreenLogicNumberDescription( - NumberEntityDescription, - ScreenLogicEntityDescription, - ScreenLogicNumberRequiredMixin, -): - """Describes a ScreenLogic number entity.""" - - class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): """Class to represent a ScreenLogic Number entity.""" @@ -166,9 +104,30 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): entity_description: ScreenLogicNumberDescription, ) -> None: """Initialize a ScreenLogic number entity.""" - self._set_value_func = entity_description.set_value - self._set_value_params = entity_description.set_value_params super().__init__(coordinator, entity_description) + if not callable( + func := getattr(self.gateway, entity_description.set_value_name) + ): + raise TypeError( + f"set_value_name '{entity_description.set_value_name}' is not a callable" + ) + self._set_value_func: Callable[..., 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) + ) + if entity_description.native_max_value is None and isinstance( + max_val := self.entity_data.get(ATTR.MAX_SETPOINT), int | float + ): + self._attr_native_max_value = max_val + if entity_description.native_min_value is None and isinstance( + min_val := self.entity_data.get(ATTR.MIN_SETPOINT), int | float + ): + self._attr_native_min_value = min_val + if entity_description.native_step is None and isinstance( + step := self.entity_data.get(ATTR.STEP), int | float + ): + self._attr_native_step = step @property def native_value(self) -> float: @@ -182,12 +141,9 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): # gathers the existing values and updates the particular value being # set by this entity. args = {} - for data_path in self._set_value_params: - data_path = realize_path_template(data_path, self._data_path) - data_value = data_path[-1] - args[data_value] = self.coordinator.gateway.get_value( - *data_path, strict=True - ) + 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) args[self._data_key] = value diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 39805173961..bbcf8458014 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,10 +1,11 @@ """Support for a ScreenLogic Sensor.""" from collections.abc import Callable +from copy import copy from dataclasses import dataclass import logging -from screenlogicpy.const.common import DEVICE_TYPE, STATE_TYPE from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.chemistry import DOSE_STATE from screenlogicpy.device_const.pump import PUMP_TYPE from screenlogicpy.device_const.system import EQUIPMENT_FLAG @@ -17,221 +18,23 @@ from homeassistant.components.sensor import ( SensorStateClass, ) 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 as SL_DOMAIN, ScreenLogicDataPath +from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .data import ( - DEVICE_INCLUSION_RULES, - DEVICE_SUBSCRIPTION, - PathPart, - ScreenLogicDataRule, - ScreenLogicEquipmentRule, - SupportedValueParameters, - build_base_entity_description, - get_ha_unit, - iterate_expand_group_wildcard, - preprocess_supported_values, -) from .entity import ( ScreenlogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, ) -from .util import cleanup_excluded_entity, generate_unique_id +from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) -@dataclass -class SupportedSensorValueParameters(SupportedValueParameters): - """Supported predefined data for a ScreenLogic sensor entity.""" - - device_class: SensorDeviceClass | None = None - value_modification: Callable[[int], int | str] | None = lambda val: val - - -SUPPORTED_DATA: list[ - tuple[ScreenLogicDataPath, SupportedValueParameters] -] = preprocess_supported_values( - { - DEVICE.CONTROLLER: { - GROUP.SENSOR: { - VALUE.AIR_TEMPERATURE: SupportedSensorValueParameters( - device_class=SensorDeviceClass.TEMPERATURE, entity_category=None - ), - VALUE.ORP: SupportedSensorValueParameters( - included=ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags - ) - ), - VALUE.PH: SupportedSensorValueParameters( - included=ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags - ) - ), - }, - }, - DEVICE.PUMP: { - "*": { - VALUE.WATTS_NOW: SupportedSensorValueParameters(), - VALUE.GPM_NOW: SupportedSensorValueParameters( - enabled=ScreenLogicDataRule( - lambda pump_data: pump_data[VALUE.TYPE] - != PUMP_TYPE.INTELLIFLO_VS, - (PathPart.DEVICE, PathPart.INDEX), - ) - ), - VALUE.RPM_NOW: SupportedSensorValueParameters( - enabled=ScreenLogicDataRule( - lambda pump_data: pump_data[VALUE.TYPE] - != PUMP_TYPE.INTELLIFLO_VF, - (PathPart.DEVICE, PathPart.INDEX), - ) - ), - }, - }, - DEVICE.INTELLICHEM: { - GROUP.SENSOR: { - VALUE.ORP_NOW: SupportedSensorValueParameters(), - VALUE.ORP_SUPPLY_LEVEL: SupportedSensorValueParameters( - value_modification=lambda val: val - 1 - ), - VALUE.PH_NOW: SupportedSensorValueParameters(), - VALUE.PH_PROBE_WATER_TEMP: SupportedSensorValueParameters(), - VALUE.PH_SUPPLY_LEVEL: SupportedSensorValueParameters( - value_modification=lambda val: val - 1 - ), - VALUE.SATURATION: SupportedSensorValueParameters(), - }, - GROUP.CONFIGURATION: { - VALUE.CALCIUM_HARNESS: SupportedSensorValueParameters(), - VALUE.CYA: SupportedSensorValueParameters(), - VALUE.ORP_SETPOINT: SupportedSensorValueParameters(), - VALUE.PH_SETPOINT: SupportedSensorValueParameters(), - VALUE.SALT_TDS_PPM: SupportedSensorValueParameters( - included=ScreenLogicEquipmentRule( - lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags - and EQUIPMENT_FLAG.CHLORINATOR not in flags, - ) - ), - VALUE.TOTAL_ALKALINITY: SupportedSensorValueParameters(), - }, - GROUP.DOSE_STATUS: { - VALUE.ORP_DOSING_STATE: SupportedSensorValueParameters( - value_modification=lambda val: DOSE_STATE(val).title, - ), - VALUE.ORP_LAST_DOSE_TIME: SupportedSensorValueParameters(), - VALUE.ORP_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), - VALUE.PH_DOSING_STATE: SupportedSensorValueParameters( - value_modification=lambda val: DOSE_STATE(val).title, - ), - VALUE.PH_LAST_DOSE_TIME: SupportedSensorValueParameters(), - VALUE.PH_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), - }, - }, - DEVICE.SCG: { - GROUP.SENSOR: { - VALUE.SALT_PPM: SupportedSensorValueParameters(), - }, - GROUP.CONFIGURATION: { - VALUE.SUPER_CHLOR_TIMER: SupportedSensorValueParameters(), - }, - }, - } -) - -SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = { - DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION, - DEVICE_TYPE.ENUM: SensorDeviceClass.ENUM, - DEVICE_TYPE.ENERGY: SensorDeviceClass.POWER, - DEVICE_TYPE.POWER: SensorDeviceClass.POWER, - DEVICE_TYPE.TEMPERATURE: SensorDeviceClass.TEMPERATURE, - DEVICE_TYPE.VOLUME: SensorDeviceClass.VOLUME, -} - -SL_STATE_TYPE_TO_HA_STATE_CLASS = { - STATE_TYPE.MEASUREMENT: SensorStateClass.MEASUREMENT, - STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up entry.""" - entities: list[ScreenLogicSensor] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ - config_entry.entry_id - ] - gateway = coordinator.gateway - data_path: ScreenLogicDataPath - value_params: SupportedSensorValueParameters - for data_path, value_params in iterate_expand_group_wildcard( - gateway, SUPPORTED_DATA - ): - entity_key = generate_unique_id(*data_path) - - device = data_path[0] - - if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( - gateway, data_path - ): - cleanup_excluded_entity(coordinator, DOMAIN, entity_key) - continue - - try: - value_data = gateway.get_data(*data_path, strict=True) - except KeyError: - _LOGGER.debug("Failed to find %s", data_path) - continue - - entity_description_kwargs = { - **build_base_entity_description( - gateway, entity_key, data_path, value_data, value_params - ), - "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( - value_data.get(ATTR.DEVICE_TYPE) - ), - "native_unit_of_measurement": get_ha_unit(value_data), - "options": value_data.get(ATTR.ENUM_OPTIONS), - "state_class": SL_STATE_TYPE_TO_HA_STATE_CLASS.get( - value_data.get(ATTR.STATE_TYPE) - ), - "value_mod": value_params.value_modification, - } - - if ( - sub_code := ( - value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) - ) - ) is not None: - entities.append( - ScreenLogicPushSensor( - coordinator, - ScreenLogicPushSensorDescription( - subscription_code=sub_code, - **entity_description_kwargs, - ), - ) - ) - else: - entities.append( - ScreenLogicSensor( - coordinator, - ScreenLogicSensorDescription( - **entity_description_kwargs, - ), - ) - ) - - async_add_entities(entities) - - @dataclass class ScreenLogicSensorMixin: """Mixin for SecreenLogic sensor entity.""" @@ -246,12 +49,265 @@ class ScreenLogicSensorDescription( """Describes a ScreenLogic sensor.""" +@dataclass +class ScreenLogicPushSensorDescription( + ScreenLogicSensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic push sensor.""" + + +SUPPORTED_CORE_SENSORS = [ + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.AIR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +SUPPORTED_PUMP_SENSORS = [ + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.WATTS_NOW, + device_class=SensorDeviceClass.POWER, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.GPM_NOW, + enabled_lambda=lambda type: type != PUMP_TYPE.INTELLIFLO_VS, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.PUMP,), + key=VALUE.RPM_NOW, + enabled_lambda=lambda type: type != PUMP_TYPE.INTELLIFLO_VF, + ), +] + +SUPPORTED_INTELLICHEM_SENSORS = [ + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.ORP, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.STATUS_CHANGED, + data_root=(DEVICE.CONTROLLER, GROUP.SENSOR), + key=VALUE.PH, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.ORP_NOW, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_NOW, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.ORP_SUPPLY_LEVEL, + state_class=SensorStateClass.MEASUREMENT, + value_mod=lambda val: int(val) - 1, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_SUPPLY_LEVEL, + state_class=SensorStateClass.MEASUREMENT, + value_mod=lambda val: int(val) - 1, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.PH_PROBE_WATER_TEMP, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.SENSOR), + key=VALUE.SATURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.CALCIUM_HARNESS, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.CYA, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.ORP_SETPOINT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.PH_SETPOINT, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.TOTAL_ALKALINITY, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.SALT_TDS_PPM, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_DOSING_STATE, + device_class=SensorDeviceClass.ENUM, + options=["Dosing", "Mixing", "Monitoring"], + value_mod=lambda val: DOSE_STATE(val).title, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_LAST_DOSE_TIME, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.ORP_LAST_DOSE_VOLUME, + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_DOSING_STATE, + device_class=SensorDeviceClass.ENUM, + options=["Dosing", "Mixing", "Monitoring"], + value_mod=lambda val: DOSE_STATE(val).title, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_LAST_DOSE_TIME, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ScreenLogicPushSensorDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.DOSE_STATUS), + key=VALUE.PH_LAST_DOSE_VOLUME, + device_class=SensorDeviceClass.VOLUME, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +] + +SUPPORTED_SCG_SENSORS = [ + ScreenLogicSensorDescription( + data_root=(DEVICE.SCG, GROUP.SENSOR), + key=VALUE.SALT_PPM, + state_class=SensorStateClass.MEASUREMENT, + ), + ScreenLogicSensorDescription( + data_root=(DEVICE.SCG, GROUP.CONFIGURATION), + key=VALUE.SUPER_CHLOR_TIMER, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry.""" + entities: list[ScreenLogicSensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ + config_entry.entry_id + ] + gateway = coordinator.gateway + + for core_sensor_description in SUPPORTED_CORE_SENSORS: + if ( + gateway.get_data( + *core_sensor_description.data_root, core_sensor_description.key + ) + is not None + ): + entities.append(ScreenLogicPushSensor(coordinator, core_sensor_description)) + + for pump_index, pump_data in gateway.get_data(DEVICE.PUMP).items(): + if not pump_data or not pump_data.get(VALUE.DATA): + continue + pump_type = pump_data[VALUE.TYPE] + for proto_pump_sensor_description in SUPPORTED_PUMP_SENSORS: + if not pump_data.get(proto_pump_sensor_description.key): + continue + entities.append( + ScreenLogicPumpSensor( + coordinator, + copy(proto_pump_sensor_description), + pump_index, + pump_type, + ) + ) + + chem_sensor_description: ScreenLogicPushSensorDescription + for chem_sensor_description in SUPPORTED_INTELLICHEM_SENSORS: + chem_sensor_data_path = ( + *chem_sensor_description.data_root, + chem_sensor_description.key, + ) + if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) + continue + if gateway.get_data(*chem_sensor_data_path): + chem_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + entities.append(ScreenLogicPushSensor(coordinator, chem_sensor_description)) + + scg_sensor_description: ScreenLogicSensorDescription + for scg_sensor_description in SUPPORTED_SCG_SENSORS: + scg_sensor_data_path = ( + *scg_sensor_description.data_root, + scg_sensor_description.key, + ) + if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) + continue + if gateway.get_data(*scg_sensor_data_path): + scg_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + entities.append(ScreenLogicSensor(coordinator, scg_sensor_description)) + + async_add_entities(entities) + + class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): """Representation of a ScreenLogic sensor entity.""" entity_description: ScreenLogicSensorDescription _attr_has_entity_name = True + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicSensorDescription, + ) -> None: + """Initialize of the entity.""" + super().__init__(coordinator, entity_description) + self._attr_native_unit_of_measurement = get_ha_unit( + self.entity_data.get(ATTR.UNIT) + ) + @property def native_value(self) -> str | int | float: """State of the sensor.""" @@ -260,14 +316,29 @@ class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): return value_mod(val) if value_mod else val -@dataclass -class ScreenLogicPushSensorDescription( - ScreenLogicSensorDescription, ScreenLogicPushEntityDescription -): - """Describes a ScreenLogic push sensor.""" - - class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity): """Representation of a ScreenLogic push sensor entity.""" entity_description: ScreenLogicPushSensorDescription + + +class ScreenLogicPumpSensor(ScreenLogicSensor): + """Representation of a ScreenLogic pump sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicSensorDescription, + pump_index: int, + pump_type: int, + ) -> None: + """Initialize of the entity.""" + entity_description.data_root = (DEVICE.PUMP, pump_index) + super().__init__(coordinator, entity_description) + if entity_description.enabled_lambda: + self._attr_entity_registry_enabled_default = ( + entity_description.enabled_lambda(pump_type) + ) diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 247ec4f2f03..4900ed938a1 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -30,7 +30,11 @@ async def async_setup_entry( ] gateway = coordinator.gateway for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): - if circuit_data[ATTR.FUNCTION] in LIGHT_CIRCUIT_FUNCTIONS: + if ( + not circuit_data + or ((circuit_function := circuit_data.get(ATTR.FUNCTION)) is None) + or circuit_function in LIGHT_CIRCUIT_FUNCTIONS + ): continue circuit_name = circuit_data[ATTR.NAME] circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) @@ -39,9 +43,8 @@ async def async_setup_entry( coordinator, ScreenLogicSwitchDescription( subscription_code=CODE.STATUS_CHANGED, - data_path=(DEVICE.CIRCUIT, circuit_index), + data_root=(DEVICE.CIRCUIT,), key=circuit_index, - name=circuit_name, entity_registry_enabled_default=( circuit_name not in GENERIC_CIRCUIT_NAMES and circuit_interface != INTERFACE.DONT_SHOW diff --git a/homeassistant/components/screenlogic/util.py b/homeassistant/components/screenlogic/util.py index c8d9d5f0f77..928effc73fc 100644 --- a/homeassistant/components/screenlogic/util.py +++ b/homeassistant/components/screenlogic/util.py @@ -5,32 +5,40 @@ from screenlogicpy.const.data import SHARED_VALUES from homeassistant.helpers import entity_registry as er -from .const import DOMAIN as SL_DOMAIN +from .const import DOMAIN as SL_DOMAIN, SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -def generate_unique_id( - device: str | int, group: str | int | None, data_key: str | int -) -> str: +def generate_unique_id(*args: str | int | None) -> str: """Generate new unique_id for a screenlogic entity from specified parameters.""" - if data_key in SHARED_VALUES and device is not None: - if group is not None and (isinstance(group, int) or group.isdigit()): - return f"{device}_{group}_{data_key}" - return f"{device}_{data_key}" - return str(data_key) + _LOGGER.debug("gen_uid called with %s", args) + if len(args) == 3: + if args[2] in SHARED_VALUES: + if args[1] is not None and (isinstance(args[1], int) or args[1].isdigit()): + return f"{args[0]}_{args[1]}_{args[2]}" + return f"{args[0]}_{args[2]}" + return f"{args[2]}" + return f"{args[1]}" + + +def get_ha_unit(sl_unit) -> str: + """Return equivalent Home Assistant unit of measurement if exists.""" + if (ha_unit := SL_UNIT_TO_HA_UNIT.get(sl_unit)) is not None: + return ha_unit + return sl_unit def cleanup_excluded_entity( coordinator: ScreenlogicDataUpdateCoordinator, platform_domain: str, - entity_key: str, + data_path: ScreenLogicDataPath, ) -> None: """Remove excluded entity if it exists.""" assert coordinator.config_entry entity_registry = er.async_get(coordinator.hass) - unique_id = f"{coordinator.config_entry.unique_id}_{entity_key}" + unique_id = f"{coordinator.config_entry.unique_id}_{generate_unique_id(*data_path)}" if entity_id := entity_registry.async_get_entity_id( platform_domain, SL_DOMAIN, unique_id ): diff --git a/tests/components/screenlogic/__init__.py b/tests/components/screenlogic/__init__.py index 48362722312..e5400e3ca15 100644 --- a/tests/components/screenlogic/__init__.py +++ b/tests/components/screenlogic/__init__.py @@ -35,12 +35,21 @@ def num_key_string_to_int(data: dict) -> None: DATA_FULL_CHEM = num_key_string_to_int( load_json_object_fixture("screenlogic/data_full_chem.json") ) +DATA_FULL_NO_GPM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_no_gpm.json") +) +DATA_FULL_NO_SALT_PPM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_no_salt_ppm.json") +) DATA_MIN_MIGRATION = num_key_string_to_int( load_json_object_fixture("screenlogic/data_min_migration.json") ) DATA_MIN_ENTITY_CLEANUP = num_key_string_to_int( load_json_object_fixture("screenlogic/data_min_entity_cleanup.json") ) +DATA_MISSING_VALUES_CHEM_CHLOR = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_missing_values_chem_chlor.json") +) async def stub_async_connect( diff --git a/tests/components/screenlogic/fixtures/data_full_no_gpm.json b/tests/components/screenlogic/fixtures/data_full_no_gpm.json new file mode 100644 index 00000000000..93e3040f911 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_no_gpm.json @@ -0,0 +1,784 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 738.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 1, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 7, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 1, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "IntelliTouch i7+3" + }, + "equipment": { + "flags": 56, + "list": ["INTELLIBRITE", "INTELLIFLO_0", "INTELLIFLO_1"] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 91, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.0, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 0, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 0, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_90": 0, + "unknown_at_offset_91": 0, + "delay": 0 + }, + "function": 5, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Jets", + "configuration": { + "name_index": 45, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_114": 0, + "unknown_at_offset_115": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_146": 0, + "unknown_at_offset_147": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_178": 0, + "unknown_at_offset_179": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 0, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool", + "configuration": { + "name_index": 60, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_202": 0, + "unknown_at_offset_203": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 1 + }, + "506": { + "circuit_id": 506, + "name": "Air Blower", + "configuration": { + "name_index": 1, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_234": 0, + "unknown_at_offset_235": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + } + }, + "pump": { + "0": { + "data": 134, + "type": 2, + "state": { + "name": "Pool Pump", + "value": 1 + }, + "watts_now": { + "name": "Pool Pump Watts Now", + "value": 63, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Pump RPM Now", + "value": 1050, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 1050, + "is_rpm": 1 + }, + "1": { + "device_id": 1, + "setpoint": 1850, + "is_rpm": 1 + }, + "2": { + "device_id": 2, + "setpoint": 1500, + "is_rpm": 1 + }, + "3": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "4": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "5": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "6": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "7": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + } + } + }, + "1": { + "data": 131, + "type": 2, + "state": { + "name": "Jets Pump", + "value": 0 + }, + "watts_now": { + "name": "Jets Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Jets Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Jets Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 3, + "setpoint": 2970, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "2": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "3": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "4": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "5": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "6": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + }, + "7": { + "device_id": 0, + "setpoint": 1000, + "is_rpm": 1 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 86, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 85, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 102, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 91, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 3, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 0, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 0, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.0, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 0, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 0.0, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 0, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 0, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 0, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 0, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 0, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 0 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 0, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 0, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 0, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 0, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 0, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 0, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 0, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 0, + "flow_alarm": { + "name": "Flow Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "0.000" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 0, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 1 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 50, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json new file mode 100644 index 00000000000..d17d0e41170 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json @@ -0,0 +1,859 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 60, + "list": ["CHLORINATOR", "INTELLIBRITE", "INTELLIFLO_0", "INTELLIFLO_1"] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 7.61, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Pool Low Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 50, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json new file mode 100644 index 00000000000..c30ee690f8a --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json @@ -0,0 +1,849 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 32828, + "list": [ + "CHLORINATOR", + "INTELLIBRITE", + "INTELLIFLO_0", + "INTELLIFLO_1", + "INTELLICHEM" + ] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 50, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/test_data.py b/tests/components/screenlogic/test_data.py index 9686dc81586..ead064f7d93 100644 --- a/tests/components/screenlogic/test_data.py +++ b/tests/components/screenlogic/test_data.py @@ -1,12 +1,9 @@ """Tests for ScreenLogic integration data processing.""" from unittest.mock import DEFAULT, patch -import pytest from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from homeassistant.components.screenlogic import DOMAIN -from homeassistant.components.screenlogic.data import PathPart, realize_path_template from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -71,21 +68,3 @@ async def test_async_cleanup_entries( deleted_entity = entity_registry.async_get(unused_entity.entity_id) assert deleted_entity is None - - -def test_realize_path_templates() -> None: - """Test path template realization.""" - assert realize_path_template( - (PathPart.DEVICE, PathPart.INDEX), (DEVICE.PUMP, 0, VALUE.WATTS_NOW) - ) == (DEVICE.PUMP, 0) - - assert realize_path_template( - (PathPart.DEVICE, PathPart.INDEX, PathPart.VALUE, ATTR.NAME_INDEX), - (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION), - ) == (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION, ATTR.NAME_INDEX) - - with pytest.raises(KeyError): - realize_path_template( - (PathPart.DEVICE, PathPart.KEY, ATTR.VALUE), - (DEVICE.ADAPTER, VALUE.FIRMWARE), - ) diff --git a/tests/components/screenlogic/test_init.py b/tests/components/screenlogic/test_init.py index 3b99354a1df..cf0a7ef3f38 100644 --- a/tests/components/screenlogic/test_init.py +++ b/tests/components/screenlogic/test_init.py @@ -6,6 +6,7 @@ import pytest from screenlogicpy import ScreenLogicGateway from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.screenlogic import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -14,6 +15,7 @@ from homeassistant.util import slugify from . import ( DATA_MIN_MIGRATION, + DATA_MISSING_VALUES_CHEM_CHLOR, GATEWAY_DISCOVERY_IMPORT_PATH, MOCK_ADAPTER_MAC, MOCK_ADAPTER_NAME, @@ -77,6 +79,13 @@ TEST_MIGRATING_ENTITIES = [ "old_sensor", SENSOR_DOMAIN, ), + EntityMigrationData( + "Pump Sensor Missing Index", + "currentWatts", + "Pump Sensor Missing Index", + "currentWatts", + SENSOR_DOMAIN, + ), ] MIGRATION_CONNECT = lambda *args, **kwargs: stub_async_connect( @@ -234,3 +243,37 @@ async def test_entity_migration_data( entity_not_migrated = entity_registry.async_get(old_eid) assert entity_not_migrated == original_entity + + +async def test_platform_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test setup for platforms that define expected data.""" + stub_connect = lambda *args, **kwargs: stub_async_connect( + DATA_MISSING_VALUES_CHEM_CHLOR, *args, **kwargs + ) + + device_prefix = slugify(MOCK_ADAPTER_NAME) + + tested_entity_ids = [ + f"{BINARY_SENSOR_DOMAIN}.{device_prefix}_active_alert", + f"{SENSOR_DOMAIN}.{device_prefix}_air_temperature", + f"{NUMBER_DOMAIN}.{device_prefix}_pool_chlorinator_setpoint", + ] + + mock_config_entry.add_to_hass(hass) + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=stub_connect, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + for entity_id in tested_entity_ids: + assert hass.states.get(entity_id) is not None From 8a44adb447636927a03110b336924e6420093f6e Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 25 Sep 2023 13:34:39 +0200 Subject: [PATCH 755/984] Add binary sensors for duotecno (#100844) * Add binary sensors for duotecno * Add comments --- .coveragerc | 1 + homeassistant/components/duotecno/__init__.py | 1 + .../components/duotecno/binary_sensor.py | 34 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 homeassistant/components/duotecno/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index c72c570feab..7a016dac370 100644 --- a/.coveragerc +++ b/.coveragerc @@ -247,6 +247,7 @@ omit = homeassistant/components/duotecno/cover.py homeassistant/components/duotecno/light.py homeassistant/components/duotecno/climate.py + homeassistant/components/duotecno/binary_sensor.py homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index bc7d519aa9c..d9d890c28f3 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -16,6 +16,7 @@ PLATFORMS: list[Platform] = [ Platform.COVER, Platform.LIGHT, Platform.CLIMATE, + Platform.BINARY_SENSOR, ] diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py new file mode 100644 index 00000000000..a1638ce4055 --- /dev/null +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -0,0 +1,34 @@ +"""Support for Duotecno binary sensors.""" + +from duotecno.unit import ControlUnit + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import DuotecnoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Duotecno binary sensor on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoBinarySensor(channel) for channel in cntrl.get_units("ControlUnit") + ) + + +class DuotecnoBinarySensor(DuotecnoEntity, BinarySensorEntity): + """Representation of a DuotecnoBinarySensor.""" + + _unit: ControlUnit + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._unit.is_on() From 23b239ba77424e3eee1b4d6e8c917328453bc02c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 25 Sep 2023 09:33:54 -0400 Subject: [PATCH 756/984] Allow passing a wake word ID to detect wake word (#100832) * Allow passing a wake word ID to detect wake word * Do not inject default wake words in wake_word integration --- .../components/wake_word/__init__.py | 6 ++--- homeassistant/components/wyoming/wake_word.py | 2 +- tests/components/assist_pipeline/conftest.py | 6 +++-- .../wake_word/snapshots/test_init.ambr | 5 +++- tests/components/wake_word/test_init.py | 25 +++++++++++++++---- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index b308cf98912..c29789a5fc8 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -88,7 +88,7 @@ class WakeWordDetectionEntity(RestoreEntity): @abstractmethod async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps. @@ -96,13 +96,13 @@ class WakeWordDetectionEntity(RestoreEntity): """ async def async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None = None ) -> DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps. Audio must be 16Khz sample rate with 16-bit mono PCM samples. """ - result = await self._async_process_audio_stream(stream) + result = await self._async_process_audio_stream(stream, wake_word_id) if result is not None: # Update last detected only when there is a detection self.__last_detected = dt_util.utcnow().isoformat() diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 0e7fb3c4429..710e3408c5a 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -58,7 +58,7 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): return self._supported_wake_words async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect one or more wake words in an audio stream. diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index d2ec3553cf0..0ea92dd42fd 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -187,13 +187,15 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" + if wake_word_id is None: + wake_word_id = self.supported_wake_words[0].ww_id async for chunk, timestamp in stream: if chunk.startswith(b"wake word"): return wake_word.DetectionResult( - ww_id=self.supported_wake_words[0].ww_id, + ww_id=wake_word_id, timestamp=timestamp, queued_audio=[(b"queued audio", 0)], ) diff --git a/tests/components/wake_word/snapshots/test_init.ambr b/tests/components/wake_word/snapshots/test_init.ambr index cf7c09cd730..60439d1109b 100644 --- a/tests/components/wake_word/snapshots/test_init.ambr +++ b/tests/components/wake_word/snapshots/test_init.ambr @@ -1,5 +1,8 @@ # serializer version: 1 -# name: test_detected_entity +# name: test_detected_entity[None-test_ww] + None +# --- +# name: test_detected_entity[test_ww_2-test_ww_2] None # --- # name: test_ws_detect diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index d37cb3aa540..e54bfc97214 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -39,16 +39,22 @@ class MockProviderEntity(wake_word.WakeWordDetectionEntity): @property def supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" - return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] + return [ + wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word"), + wake_word.WakeWord(ww_id="test_ww_2", name="Test Wake Word 2"), + ] async def _async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]] + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" + if wake_word_id is None: + wake_word_id = self.supported_wake_words[0].ww_id + async for _chunk, timestamp in stream: if timestamp >= 2000: return wake_word.DetectionResult( - ww_id=self.supported_wake_words[0].ww_id, timestamp=timestamp + ww_id=wake_word_id, timestamp=timestamp ) # Not detected @@ -148,11 +154,20 @@ async def test_config_entry_unload( assert config_entry.state == ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("ww_id", "expected_ww"), + [ + (None, "test_ww"), + ("test_ww_2", "test_ww_2"), + ], +) async def test_detected_entity( hass: HomeAssistant, tmp_path: Path, setup: MockProviderEntity, snapshot: SnapshotAssertion, + ww_id: str | None, + expected_ww: str, ) -> None: """Test successful detection through entity.""" @@ -164,8 +179,8 @@ async def test_detected_entity( # Need 2 seconds to trigger state = setup.state - result = await setup.async_process_audio_stream(three_second_stream()) - assert result == wake_word.DetectionResult("test_ww", 2048) + result = await setup.async_process_audio_stream(three_second_stream(), ww_id) + assert result == wake_word.DetectionResult(expected_ww, 2048) assert state != setup.state assert state == snapshot From 7334bc7c9b06ba42df7b64c60d5f8ee0a7cb77ec Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 25 Sep 2023 14:53:01 +0100 Subject: [PATCH 757/984] Add a select entity for homekit temperature display units (#100853) --- .../components/homekit_controller/const.py | 1 + .../homekit_controller/manifest.json | 2 +- .../components/homekit_controller/select.py | 116 ++++++++++++++++-- .../homekit_controller/strings.json | 6 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homekit_controller/test_select.py | 84 +++++++++++++ 7 files changed, 200 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index cde9aa732c3..e5ef4b14448 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -101,6 +101,7 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.MUTE: "switch", CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor", CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch", + CharacteristicsTypes.TEMPERATURE_UNITS: "select", } STARTUP_EXCEPTIONS = ( diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index c99142da475..877c687f33e 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.3"], + "requirements": ["aiohomekit==3.0.4"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index 76067aea061..09bb57923c6 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -1,18 +1,54 @@ """Support for Homekit select entities.""" from __future__ import annotations -from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from dataclasses import dataclass +from enum import IntEnum -from homeassistant.components.select import SelectEntity +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from aiohomekit.model.characteristics.const import TemperatureDisplayUnits + +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES from .connection import HKDevice from .entity import CharacteristicEntity + +@dataclass +class HomeKitSelectEntityDescriptionRequired: + """Required fields for HomeKitSelectEntityDescription.""" + + choices: dict[str, IntEnum] + + +@dataclass +class HomeKitSelectEntityDescription( + SelectEntityDescription, HomeKitSelectEntityDescriptionRequired +): + """A generic description of a select entity backed by a single characteristic.""" + + name: str | None = None + + +SELECT_ENTITIES: dict[str, HomeKitSelectEntityDescription] = { + CharacteristicsTypes.TEMPERATURE_UNITS: HomeKitSelectEntityDescription( + key="temperature_display_units", + translation_key="temperature_display_units", + name="Temperature Display Units", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + choices={ + "celsius": TemperatureDisplayUnits.CELSIUS, + "fahrenheit": TemperatureDisplayUnits.FAHRENHEIT, + }, + ), +} + _ECOBEE_MODE_TO_TEXT = { 0: "home", 1: "sleep", @@ -21,7 +57,58 @@ _ECOBEE_MODE_TO_TEXT = { _ECOBEE_MODE_TO_NUMBERS = {v: k for (k, v) in _ECOBEE_MODE_TO_TEXT.items()} -class EcobeeModeSelect(CharacteristicEntity, SelectEntity): +class BaseHomeKitSelect(CharacteristicEntity, SelectEntity): + """Base entity for select entities backed by a single characteristics.""" + + +class HomeKitSelect(BaseHomeKitSelect): + """Representation of a select control on a homekit accessory.""" + + entity_description: HomeKitSelectEntityDescription + + def __init__( + self, + conn: HKDevice, + info: ConfigType, + char: Characteristic, + description: HomeKitSelectEntityDescription, + ) -> None: + """Initialise a HomeKit select control.""" + self.entity_description = description + + self._choice_to_enum = self.entity_description.choices + self._enum_to_choice = { + v: k for (k, v) in self.entity_description.choices.items() + } + + self._attr_options = list(self.entity_description.choices.keys()) + + super().__init__(conn, info, char) + + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity cares about.""" + return [self._char.type] + + @property + def name(self) -> str | None: + """Return the name of the device if any.""" + if name := self.accessory.name: + return f"{name} {self.entity_description.name}" + return self.entity_description.name + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self._enum_to_choice.get(self._char.value) + + async def async_select_option(self, option: str) -> None: + """Set the current option.""" + await self.async_put_characteristics( + {self._char.type: self._choice_to_enum[option]} + ) + + +class EcobeeModeSelect(BaseHomeKitSelect): """Represents a ecobee mode select entity.""" _attr_options = ["home", "sleep", "away"] @@ -64,14 +151,23 @@ async def async_setup_entry( @callback def async_add_characteristic(char: Characteristic) -> bool: - if char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: - info = {"aid": char.service.accessory.aid, "iid": char.service.iid} - entity = EcobeeModeSelect(conn, info, char) + entities: list[BaseHomeKitSelect] = [] + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + + if description := SELECT_ENTITIES.get(char.type): + entities.append(HomeKitSelect(conn, info, char, description)) + elif char.type == CharacteristicsTypes.VENDOR_ECOBEE_CURRENT_MODE: + entities.append(EcobeeModeSelect(conn, info, char)) + + if not entities: + return False + + for entity in entities: conn.async_migrate_unique_id( entity.old_unique_id, entity.unique_id, Platform.SELECT ) - async_add_entities([entity]) - return True - return False + + async_add_entities(entities) + return True conn.add_char_factory(async_add_characteristic) diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 901378c8cb9..bc61b6fd42e 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -102,6 +102,12 @@ "home": "[%key:common::state::home%]", "sleep": "Sleep" } + }, + "temperature_display_units": { + "state": { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit" + } } }, "sensor": { diff --git a/requirements_all.txt b/requirements_all.txt index 4111319c2d5..05844f2ba20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.3 +aiohomekit==3.0.4 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c49f2b1ee57..a3f898342af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.3 +aiohomekit==3.0.4 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index d9feebafc76..9cfa0bccda3 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -1,6 +1,7 @@ """Basic checks for HomeKit select entities.""" from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import TemperatureDisplayUnits from aiohomekit.model.services import ServicesTypes from homeassistant.core import HomeAssistant @@ -22,6 +23,16 @@ def create_service_with_ecobee_mode(accessory: Accessory): return service +def create_service_with_temperature_units(accessory: Accessory): + """Define a thermostat with ecobee mode characteristics.""" + service = accessory.add_service(ServicesTypes.TEMPERATURE_SENSOR, add_required=True) + + units = service.add_char(CharacteristicsTypes.TEMPERATURE_UNITS) + units.value = 0 + + return service + + async def test_migrate_unique_id(hass: HomeAssistant, utcnow) -> None: """Test we can migrate a select unique id.""" entity_registry = er.async_get(hass) @@ -125,3 +136,76 @@ async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: ServicesTypes.THERMOSTAT, {CharacteristicsTypes.VENDOR_ECOBEE_SET_HOLD_SCHEDULE: 2}, ) + + +async def test_read_select(hass: HomeAssistant, utcnow) -> None: + """Test the generic select can read the current value.""" + helper = await setup_test_component(hass, create_service_with_temperature_units) + + # Helper will be for the primary entity, which is the service. Make a helper for the sensor. + select_entity = Helper( + hass, + "select.testdevice_temperature_display_units", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + state = await select_entity.async_update( + ServicesTypes.TEMPERATURE_SENSOR, + { + CharacteristicsTypes.TEMPERATURE_UNITS: 0, + }, + ) + assert state.state == "celsius" + + state = await select_entity.async_update( + ServicesTypes.TEMPERATURE_SENSOR, + { + CharacteristicsTypes.TEMPERATURE_UNITS: 1, + }, + ) + assert state.state == "fahrenheit" + + +async def test_write_select(hass: HomeAssistant, utcnow) -> None: + """Test can set a value.""" + helper = await setup_test_component(hass, create_service_with_temperature_units) + helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) + + # Helper will be for the primary entity, which is the service. Make a helper for the sensor. + current_mode = Helper( + hass, + "select.testdevice_temperature_display_units", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.testdevice_temperature_display_units", + "option": "fahrenheit", + }, + blocking=True, + ) + current_mode.async_assert_service_values( + ServicesTypes.TEMPERATURE_SENSOR, + {CharacteristicsTypes.TEMPERATURE_UNITS: TemperatureDisplayUnits.FAHRENHEIT}, + ) + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.testdevice_temperature_display_units", + "option": "celsius", + }, + blocking=True, + ) + current_mode.async_assert_service_values( + ServicesTypes.TEMPERATURE_SENSOR, + {CharacteristicsTypes.TEMPERATURE_UNITS: TemperatureDisplayUnits.CELSIUS}, + ) From 5a3efb9149c30109a24e2e56b267102301b8c8b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 25 Sep 2023 16:07:26 +0200 Subject: [PATCH 758/984] Store wakeword settings in assist pipelines (#100847) * Store wakeword settings in assist pipelines * wakeword -> wake_word * Remove unneeded variable --- .../components/assist_pipeline/pipeline.py | 39 +++++++- tests/components/assist_pipeline/test_init.py | 6 ++ .../assist_pipeline/test_pipeline.py | 94 ++++++++++++++++++- .../components/assist_pipeline/test_select.py | 4 + .../assist_pipeline/test_websocket.py | 40 ++++++++ 5 files changed, 180 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index f4d060ed7b8..21311e150ad 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -61,6 +61,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_KEY = f"{DOMAIN}.pipelines" STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 ENGINE_LANGUAGE_PAIRS = ( ("stt_engine", "stt_language"), @@ -86,6 +87,8 @@ PIPELINE_FIELDS = { vol.Required("tts_engine"): vol.Any(str, None), vol.Required("tts_language"): vol.Any(str, None), vol.Required("tts_voice"): vol.Any(str, None), + vol.Required("wake_word_entity"): vol.Any(str, None), + vol.Required("wake_word_id"): vol.Any(str, None), } STORED_PIPELINE_RUNS = 10 @@ -111,6 +114,8 @@ async def _async_resolve_default_pipeline_settings( tts_engine = None tts_language = None tts_voice = None + wake_word_entity = None + wake_word_id = None # Find a matching language supported by the Home Assistant conversation agent conversation_languages = language_util.matches( @@ -188,6 +193,8 @@ async def _async_resolve_default_pipeline_settings( "tts_engine": tts_engine_id, "tts_language": tts_language, "tts_voice": tts_voice, + "wake_word_entity": wake_word_entity, + "wake_word_id": wake_word_id, } @@ -295,6 +302,8 @@ class Pipeline: tts_engine: str | None tts_language: str | None tts_voice: str | None + wake_word_entity: str | None + wake_word_id: str | None id: str = field(default_factory=ulid_util.ulid) @@ -316,6 +325,8 @@ class Pipeline: tts_engine=data["tts_engine"], tts_language=data["tts_language"], tts_voice=data["tts_voice"], + wake_word_entity=data["wake_word_entity"], + wake_word_id=data["wake_word_id"], ) def to_json(self) -> dict[str, Any]: @@ -331,6 +342,8 @@ class Pipeline: "tts_engine": self.tts_engine, "tts_language": self.tts_language, "tts_voice": self.tts_voice, + "wake_word_entity": self.wake_word_entity, + "wake_word_id": self.wake_word_id, } @@ -1382,11 +1395,35 @@ class PipelineRunDebug: ) +class PipelineStore(Store[SerializedPipelineStorageCollection]): + """Store entity registry data.""" + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: SerializedPipelineStorageCollection, + ) -> SerializedPipelineStorageCollection: + """Migrate to the new version.""" + if old_major_version == 1 and old_minor_version < 2: + # Version 1.2 adds wake word configuration + for pipeline in old_data["items"]: + # Populate keys which were introduced before version 1.2 + pipeline.setdefault("wake_word_entity", None) + pipeline.setdefault("wake_word_id", None) + + if old_major_version > 1: + raise NotImplementedError + return old_data + + @singleton(DOMAIN) async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: """Set up the pipeline storage collection.""" pipeline_store = PipelineStorageCollection( - Store(hass, STORAGE_VERSION, STORAGE_KEY) + PipelineStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) ) await pipeline_store.async_load() PipelineStorageCollectionWebsocket( diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 8687e2ad40c..1a7362aab80 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -103,6 +103,8 @@ async def test_pipeline_from_audio_stream_legacy( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -163,6 +165,8 @@ async def test_pipeline_from_audio_stream_entity( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -223,6 +227,8 @@ async def test_pipeline_from_audio_stream_no_stt( "tts_engine": "test", "tts_language": "en-AU", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 32468e3af91..5a84f4c2716 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -8,15 +8,16 @@ from homeassistant.components.assist_pipeline.const import DOMAIN from homeassistant.components.assist_pipeline.pipeline import ( STORAGE_KEY, STORAGE_VERSION, + STORAGE_VERSION_MINOR, Pipeline, PipelineData, PipelineStorageCollection, + PipelineStore, async_create_default_pipeline, async_get_pipeline, async_get_pipelines, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.storage import Store from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES @@ -45,6 +46,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": "tts_engine_1", "tts_language": "language_1", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", }, { "conversation_engine": "conversation_engine_2", @@ -56,6 +59,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": "tts_engine_2", "tts_language": "language_2", "tts_voice": "The Voice", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", }, { "conversation_engine": "conversation_engine_3", @@ -67,6 +72,8 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": "wakeword_entity_3", + "wake_word_id": "wakeword_id_3", }, ] pipeline_ids = [] @@ -81,7 +88,11 @@ async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: await store1.async_delete_item(pipeline_ids[1]) assert len(store1.data) == 3 - store2 = PipelineStorageCollection(Store(hass, STORAGE_VERSION, STORAGE_KEY)) + store2 = PipelineStorageCollection( + PipelineStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ) + ) await flush_store(store1.store) await store2.async_load() @@ -96,6 +107,71 @@ async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored pipelines on start.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "minor_version": STORAGE_VERSION_MINOR, + "key": "assist_pipeline.pipelines", + "data": { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "name_1", + "stt_engine": "stt_engine_1", + "stt_language": "language_1", + "tts_engine": "tts_engine_1", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_2", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": "wakeword_entity_3", + "wake_word_id": "wakeword_id_3", + }, + ], + "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + }, + } + + assert await async_setup_component(hass, "assist_pipeline", {}) + + pipeline_data: PipelineData = hass.data[DOMAIN] + store = pipeline_data.pipeline_store + assert len(store.data) == 3 + assert store.async_get_preferred_item() == "01GX8ZWBAQYWNB1XV3EXEZ75DY" + + +async def test_migrate_pipeline_store( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test loading stored pipelines from an older version.""" hass_storage[STORAGE_KEY] = { "version": 1, "minor_version": 1, @@ -173,6 +249,8 @@ async def test_create_default_pipeline( tts_engine="test", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) @@ -213,6 +291,8 @@ async def test_get_pipelines(hass: HomeAssistant) -> None: tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) ] @@ -258,6 +338,8 @@ async def test_default_pipeline_no_stt_tts( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -318,6 +400,8 @@ async def test_default_pipeline( tts_engine="test", tts_language=tts_language, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -347,6 +431,8 @@ async def test_default_pipeline_unsupported_stt_language( tts_engine="test", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) @@ -376,6 +462,8 @@ async def test_default_pipeline_unsupported_tts_language( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -424,4 +512,6 @@ async def test_default_pipeline_cloud( tts_engine="cloud", tts_language="en-US", tts_voice="james_earl_jones", + wake_word_entity=None, + wake_word_id=None, ) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 1419eb58750..090c1034e4e 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -70,6 +70,8 @@ async def pipeline_1( "tts_voice": None, "stt_engine": None, "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, } ) @@ -90,6 +92,8 @@ async def pipeline_2( "tts_voice": None, "stt_engine": None, "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, } ) diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index a7ba9063b3f..a3ca7b62eb4 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -936,6 +936,8 @@ async def test_add_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -951,6 +953,8 @@ async def test_add_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } assert len(pipeline_store.data) == 2 @@ -966,6 +970,8 @@ async def test_add_pipeline( tts_engine="test_tts_engine", tts_language="test_language", tts_voice="Arnold Schwarzenegger", + wake_word_entity="wakeword_entity_1", + wake_word_id="wakeword_id_1", ) await client.send_json_auto_id( @@ -1000,6 +1006,8 @@ async def test_add_pipeline_missing_language( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1018,6 +1026,8 @@ async def test_add_pipeline_missing_language( "tts_engine": "test_tts_engine", "tts_language": None, "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1045,6 +1055,8 @@ async def test_delete_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1063,6 +1075,8 @@ async def test_delete_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_2", + "wake_word_id": "wakeword_id_2", } ) msg = await client.receive_json() @@ -1143,6 +1157,8 @@ async def test_get_pipeline( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "james_earl_jones", + "wake_word_entity": None, + "wake_word_id": None, } await client.send_json_auto_id( @@ -1170,6 +1186,8 @@ async def test_get_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1196,6 +1214,8 @@ async def test_get_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } @@ -1221,6 +1241,8 @@ async def test_list_pipelines( "tts_engine": "test", "tts_language": "en-US", "tts_voice": "james_earl_jones", + "wake_word_entity": None, + "wake_word_id": None, } ], "preferred_pipeline": ANY, @@ -1248,6 +1270,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } ) msg = await client.receive_json() @@ -1269,6 +1293,8 @@ async def test_update_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() @@ -1289,6 +1315,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } ) msg = await client.receive_json() @@ -1304,6 +1332,8 @@ async def test_update_pipeline( "tts_engine": "new_tts_engine", "tts_language": "new_tts_language", "tts_voice": "new_tts_voice", + "wake_word_entity": "new_wakeword_entity", + "wake_word_id": "new_wakeword_id", } assert len(pipeline_store.data) == 2 @@ -1319,6 +1349,8 @@ async def test_update_pipeline( tts_engine="new_tts_engine", tts_language="new_tts_language", tts_voice="new_tts_voice", + wake_word_entity="new_wakeword_entity", + wake_word_id="new_wakeword_id", ) await client.send_json_auto_id( @@ -1334,6 +1366,8 @@ async def test_update_pipeline( "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, } ) msg = await client.receive_json() @@ -1349,6 +1383,8 @@ async def test_update_pipeline( "tts_engine": None, "tts_language": None, "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, } pipeline = pipeline_store.data[pipeline_id] @@ -1363,6 +1399,8 @@ async def test_update_pipeline( tts_engine=None, tts_language=None, tts_voice=None, + wake_word_entity=None, + wake_word_id=None, ) @@ -1386,6 +1424,8 @@ async def test_set_preferred_pipeline( "tts_engine": "test_tts_engine", "tts_language": "test_language", "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": "wakeword_entity_1", + "wake_word_id": "wakeword_id_1", } ) msg = await client.receive_json() From dd302b291df59efc89c6c44485b51ba5c0583433 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 25 Sep 2023 16:14:07 +0200 Subject: [PATCH 759/984] Update pylint to 2.17.6 (#100849) --- homeassistant/components/flo/switch.py | 1 - homeassistant/components/livisi/entity.py | 1 - homeassistant/components/zha/__init__.py | 2 +- requirements_test.txt | 4 ++-- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 5be0ffb745d..00e5e57498f 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -100,7 +100,6 @@ class FloSwitch(FloEntity, SwitchEntity): self._attr_is_on = self._device.last_known_valve_state == "open" self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index b7b9bdc8521..f76901ddb05 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -64,7 +64,6 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): ) super().__init__(coordinator) - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callback for reachability.""" await super().async_added_to_hass() diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index bd181d82a33..08db98cff6f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -170,7 +170,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: await zha_gateway.async_initialize() - except Exception: # pylint: disable=broad-except + except Exception: if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: try: await repairs.warn_on_wrong_silabs_firmware( diff --git a/requirements_test.txt b/requirements_test.txt index 2d0c256ac26..e9942a290bd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.15.4 +astroid==2.15.7 coverage==7.3.1 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 pre-commit==3.4.0 pydantic==1.10.12 -pylint==2.17.4 +pylint==2.17.6 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 From fb174f8063fef2218cc3bbfbdf629d78060c0174 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 25 Sep 2023 15:27:18 +0100 Subject: [PATCH 760/984] Add valve position sensor for Eve Thermo (#100856) --- homeassistant/components/homekit_controller/const.py | 1 + homeassistant/components/homekit_controller/sensor.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index e5ef4b14448..32ce3c7a874 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -77,6 +77,7 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT: "sensor", CharacteristicsTypes.VENDOR_EVE_DEGREE_AIR_PRESSURE: "sensor", CharacteristicsTypes.VENDOR_EVE_DEGREE_ELEVATION: "number", + CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: "sensor", CharacteristicsTypes.VENDOR_HAA_SETUP: "button", CharacteristicsTypes.VENDOR_HAA_UPDATE: "button", CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY: "sensor", diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 5803b8aa839..0f481c5c7ee 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -337,6 +337,14 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), + CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION, + name="Valve position", + icon="mdi:pipe-valve", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), } From c1b94008336cdeeaca1e5bd67fac72ec615e2edf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 09:34:53 -0500 Subject: [PATCH 761/984] Provide a better model for HomeKit service entries (#100848) --- homeassistant/components/homekit/__init__.py | 5 ++--- tests/components/homekit/test_homekit.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 514c218b101..bb4efb7db6c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -856,8 +856,7 @@ class HomeKit: connection = (dr.CONNECTION_NETWORK_MAC, formatted_mac) identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) self._async_purge_old_bridges(dev_reg, identifier, connection) - is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY - hk_mode_name = "Accessory" if is_accessory_mode else "Bridge" + accessory_type = type(self.driver.accessory).__name__ dev_reg.async_get_or_create( config_entry_id=self._entry_id, identifiers={ @@ -866,7 +865,7 @@ class HomeKit: connections={connection}, manufacturer=MANUFACTURER, name=accessory_friendly_name(self._entry_title, self.driver.accessory), - model=f"HomeKit {hk_mode_name}", + model=accessory_type, entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 02807ba6557..00281b491c4 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -765,6 +765,7 @@ async def test_homekit_start( assert device formatted_mac = dr.format_mac(homekit.driver.state.mac) assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + assert device.model == "HomeBridge" assert len(device_registry.devices) == 1 assert homekit.driver.state.config_version == 1 @@ -2010,6 +2011,16 @@ async def test_homekit_start_in_accessory_mode( assert hk_driver_start.called assert homekit.status == STATUS_RUNNING + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} + ) + assert device + formatted_mac = dr.format_mac(homekit.driver.state.mac) + assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + assert device.model == "Light" + + assert len(device_registry.devices) == 1 + async def test_homekit_start_in_accessory_mode_unsupported_entity( hass: HomeAssistant, From 4c255677c3cbea2c82b013b0ae3c0eb62e21d726 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 09:36:01 -0500 Subject: [PATCH 762/984] Add support for receivers to HomeKit (#100717) --- .../components/homekit/accessories.py | 6 ++-- homeassistant/components/homekit/const.py | 3 ++ homeassistant/components/homekit/strings.json | 4 +-- .../components/homekit/type_media_players.py | 22 +++++++++++++-- .../components/homekit/type_remotes.py | 21 ++++++++------ homeassistant/components/homekit/util.py | 3 +- .../homekit/test_get_accessories.py | 14 ++++++++-- .../homekit/test_type_media_players.py | 28 +++++++++++++++++++ 8 files changed, 83 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index f88047795ca..88422b5c957 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -183,7 +183,9 @@ def get_accessory( # noqa: C901 device_class = state.attributes.get(ATTR_DEVICE_CLASS) feature_list = config.get(CONF_FEATURE_LIST, []) - if device_class == MediaPlayerDeviceClass.TV: + if device_class == MediaPlayerDeviceClass.RECEIVER: + a_type = "ReceiverMediaPlayer" + elif device_class == MediaPlayerDeviceClass.TV: a_type = "TelevisionMediaPlayer" elif validate_media_player_features(state, feature_list): a_type = "MediaPlayer" @@ -274,7 +276,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] aid: int, config: dict, *args: Any, - category: str = CATEGORY_OTHER, + category: int = CATEGORY_OTHER, device_id: str | None = None, **kwargs: Any, ) -> None: diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 81dbf4f7e2e..bb5ae1ffd1c 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -115,6 +115,9 @@ TYPE_SPRINKLER = "sprinkler" TYPE_SWITCH = "switch" TYPE_VALVE = "valve" +# #### Categories #### +CATEGORY_RECEIVER = 34 + # #### Services #### SERV_ACCESSORY_INFO = "AccessoryInformation" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index f57536263ca..30ecfba569e 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -11,7 +11,7 @@ "include_exclude_mode": "Inclusion Mode", "domains": "[%key:component::homekit::config::step::user::data::include_domains%]" }, - "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select mode and domains." }, "accessory": { @@ -57,7 +57,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included except for categorized entities. A separate HomeKit instance in accessory mode will be created for each tv/receiver media player, activity based remote, lock, and camera.", "title": "Select domains to be included" }, "pairing": { diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index eae7ed2742a..da7fdceede3 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -1,5 +1,6 @@ """Class to hold all media player accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_SWITCH @@ -36,6 +37,7 @@ from homeassistant.core import callback from .accessories import TYPES, HomeAccessory from .const import ( ATTR_KEY_NAME, + CATEGORY_RECEIVER, CHAR_ACTIVE, CHAR_MUTE, CHAR_NAME, @@ -218,18 +220,20 @@ class MediaPlayer(HomeAccessory): class TelevisionMediaPlayer(RemoteInputSelectAccessory): """Generate a Television Media Player accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a Television Media Player accessory object.""" super().__init__( MediaPlayerEntityFeature.SELECT_SOURCE, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, *args, + **kwargs, ) state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self.chars_speaker = [] + self.chars_speaker: list[str] = [] self._supports_play_pause = features & ( MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE @@ -358,3 +362,17 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): self.char_mute.set_value(current_mute_state) self._async_update_input_state(hk_state, new_state) + + +@TYPES.register("ReceiverMediaPlayer") +class ReceiverMediaPlayer(TelevisionMediaPlayer): + """Generate a Receiver Media Player accessory. + + For HomeKit, a Receiver Media Player is exactly the same as a + Television Media Player except it has a different category + which will tell HomeKit how to render the device. + """ + + def __init__(self, *args: Any) -> None: + """Initialize a Receiver Media Player accessory object.""" + super().__init__(*args, category=CATEGORY_RECEIVER) diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 69441b5ebe1..e440a5b3ac0 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -1,6 +1,7 @@ """Class to hold remote accessories.""" from abc import ABC, abstractmethod import logging +from typing import Any from pyhap.const import CATEGORY_TELEVISION @@ -80,19 +81,21 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): def __init__( self, - required_feature, - source_key, - source_list_key, - *args, - **kwargs, - ): + required_feature: int, + source_key: str, + source_list_key: str, + *args: Any, + category: int = CATEGORY_TELEVISION, + **kwargs: Any, + ) -> None: """Initialize a InputSelect accessory object.""" - super().__init__(*args, category=CATEGORY_TELEVISION, **kwargs) + super().__init__(*args, category=category, **kwargs) state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self._mapped_sources_list = [] - self._mapped_sources = {} + self._mapped_sources_list: list[str] = [] + self._mapped_sources: dict[str, str] = {} self.source_key = source_key self.source_list_key = source_list_key self.sources = [] diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 8287c2b7845..151b97f2cda 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -614,7 +614,8 @@ def state_needs_accessory_mode(state: State) -> bool: return ( state.domain == MEDIA_PLAYER_DOMAIN - and state.attributes.get(ATTR_DEVICE_CLASS) == MediaPlayerDeviceClass.TV + and state.attributes.get(ATTR_DEVICE_CLASS) + in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER) or state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index b57dd2da10f..960647a22e6 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -17,7 +17,10 @@ from homeassistant.components.homekit.const import ( TYPE_SWITCH, TYPE_VALVE, ) -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntityFeature, +) from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( @@ -202,7 +205,14 @@ def test_type_covers(type_name, entity_id, state, attrs) -> None: "TelevisionMediaPlayer", "media_player.tv", "on", - {ATTR_DEVICE_CLASS: "tv"}, + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV}, + {}, + ), + ( + "ReceiverMediaPlayer", + "media_player.receiver", + "on", + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.RECEIVER}, {}, ), ], diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index f68adc24077..3842303ec84 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -1,6 +1,7 @@ """Test different accessory types: Media Players.""" import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, @@ -15,6 +16,7 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.components.homekit.type_media_players import ( MediaPlayer, + ReceiverMediaPlayer, TelevisionMediaPlayer, ) from homeassistant.components.media_player import ( @@ -629,3 +631,29 @@ async def test_media_player_television_unsafe_chars( assert events[-1].data[ATTR_VALUE] is None assert acc.char_input_source.value == 4 + + +async def test_media_player_receiver( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test if television accessory with unsafe characters.""" + entity_id = "media_player.receiver" + sources = ["MUSIC", "HDMI 3/ARC", "SCREEN MIRRORING", "HDMI 2/MHL", "HDMI", "MUSIC"] + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 2/MHL", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + acc = ReceiverMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 34 # Receiver From 8ed0f05270f74783c6f29ad49fc7e4a3da73817c Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 25 Sep 2023 15:52:27 +0100 Subject: [PATCH 763/984] Add duration and sensitivity configuration for Eve Motion (#100861) --- homeassistant/components/homekit_controller/const.py | 2 ++ .../components/homekit_controller/number.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 32ce3c7a874..f60dc669968 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -77,6 +77,8 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.VENDOR_EVE_ENERGY_WATT: "sensor", CharacteristicsTypes.VENDOR_EVE_DEGREE_AIR_PRESSURE: "sensor", CharacteristicsTypes.VENDOR_EVE_DEGREE_ELEVATION: "number", + CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: "number", + CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: "number", CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: "sensor", CharacteristicsTypes.VENDOR_HAA_SETUP: "button", CharacteristicsTypes.VENDOR_HAA_UPDATE: "button", diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index b44aed16143..c453efb8219 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -49,6 +49,18 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = { icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, ), + CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: NumberEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION, + name="Duration", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + ), + CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: NumberEntityDescription( + key=CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY, + name="Sensitivity", + icon="mdi:knob", + entity_category=EntityCategory.CONFIG, + ), } From 803d24ad1a5adbbe69dc0a04fc39eb7e2c014c7f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 25 Sep 2023 17:08:37 +0200 Subject: [PATCH 764/984] Rename wake_word.async_default_engine to wake_word.async_default_entity (#100855) * Rename wake_word.async_default_engine to wake_word.async_default_entity * tweak * Some more rename * Update tests --- .../components/assist_pipeline/pipeline.py | 26 +++++++++---------- .../components/wake_word/__init__.py | 6 ++--- .../assist_pipeline/snapshots/test_init.ambr | 2 +- .../snapshots/test_websocket.ambr | 6 ++--- .../assist_pipeline/test_websocket.py | 4 +-- tests/components/wake_word/test_init.py | 8 +++--- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 21311e150ad..8e297b38797 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -413,8 +413,8 @@ class PipelineRun: stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False) tts_engine: str = field(init=False) tts_options: dict | None = field(init=False, default=None) - wake_word_engine: str = field(init=False) - wake_word_provider: wake_word.WakeWordDetectionEntity = field(init=False) + wake_word_entity_id: str = field(init=False) + wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False) debug_recording_thread: Thread | None = None """Thread that records audio to debug_recording_dir""" @@ -476,24 +476,24 @@ class PipelineRun: async def prepare_wake_word_detection(self) -> None: """Prepare wake-word-detection.""" - engine = wake_word.async_default_engine(self.hass) - if engine is None: + entity_id = wake_word.async_default_entity(self.hass) + if entity_id is None: raise WakeWordDetectionError( code="wake-engine-missing", message="No wake word engine", ) - wake_word_provider = wake_word.async_get_wake_word_detection_entity( - self.hass, engine + wake_word_entity = wake_word.async_get_wake_word_detection_entity( + self.hass, entity_id ) - if wake_word_provider is None: + if wake_word_entity is None: raise WakeWordDetectionError( code="wake-provider-missing", - message=f"No wake-word-detection provider for: {engine}", + message=f"No wake-word-detection provider for: {entity_id}", ) - self.wake_word_engine = engine - self.wake_word_provider = wake_word_provider + self.wake_word_entity_id = entity_id + self.wake_word_entity = wake_word_entity async def wake_word_detection( self, @@ -519,14 +519,14 @@ class PipelineRun: PipelineEvent( PipelineEventType.WAKE_WORD_START, { - "engine": self.wake_word_engine, + "entity_id": self.wake_word_entity_id, "metadata": metadata_dict, }, ) ) if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_engine}") + self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_entity_id}") wake_word_settings = self.wake_word_settings or WakeWordSettings() @@ -548,7 +548,7 @@ class PipelineRun: try: # Detect wake word(s) - result = await self.wake_word_provider.async_process_audio_stream( + result = await self.wake_word_entity.async_process_audio_stream( self._wake_word_audio_stream( audio_stream=stream, stt_audio_buffer=stt_audio_buffer, diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index c29789a5fc8..01344a00952 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -19,7 +19,7 @@ from .const import DOMAIN from .models import DetectionResult, WakeWord __all__ = [ - "async_default_engine", + "async_default_entity", "async_get_wake_word_detection_entity", "DetectionResult", "DOMAIN", @@ -33,8 +33,8 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @callback -def async_default_engine(hass: HomeAssistant) -> str | None: - """Return the domain or entity id of the default engine.""" +def async_default_entity(hass: HomeAssistant) -> str | None: + """Return the entity id of the default engine.""" return next(iter(hass.states.async_entity_ids(DOMAIN)), None) diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 7c1cf0e2b2d..f80f294c09d 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -277,7 +277,7 @@ }), dict({ 'data': dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': , 'channel': , diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 57fbe5f4908..e8eb573b374 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -185,7 +185,7 @@ # --- # name: test_audio_pipeline_with_wake_word.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -284,7 +284,7 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -385,7 +385,7 @@ # --- # name: test_audio_pipeline_with_wake_word_timeout.1 dict({ - 'engine': 'wake_word.test', + 'entity_id': 'wake_word.test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index a3ca7b62eb4..e3561e77852 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -337,7 +337,7 @@ async def test_audio_pipeline_no_wake_word_engine( client = await hass_ws_client(hass) with patch( - "homeassistant.components.wake_word.async_default_engine", return_value=None + "homeassistant.components.wake_word.async_default_entity", return_value=None ): await client.send_json_auto_id( { @@ -367,7 +367,7 @@ async def test_audio_pipeline_no_wake_word_entity( client = await hass_ws_client(hass) with patch( - "homeassistant.components.wake_word.async_default_engine", + "homeassistant.components.wake_word.async_default_entity", return_value="wake_word.bad-entity-id", ), patch( "homeassistant.components.wake_word.async_get_wake_word_detection_entity", diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index e54bfc97214..4123e4a7e47 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -207,20 +207,20 @@ async def test_not_detected_entity( async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: - """Test async_default_engine.""" + """Test async_default_entity.""" assert await async_setup_component(hass, wake_word.DOMAIN, {wake_word.DOMAIN: {}}) await hass.async_block_till_done() - assert wake_word.async_default_engine(hass) is None + assert wake_word.async_default_entity(hass) is None async def test_default_engine_entity( hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity ) -> None: - """Test async_default_engine.""" + """Test async_default_entity.""" await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) - assert wake_word.async_default_engine(hass) == f"{wake_word.DOMAIN}.{TEST_DOMAIN}" + assert wake_word.async_default_entity(hass) == f"{wake_word.DOMAIN}.{TEST_DOMAIN}" async def test_get_engine_entity( From 84451e858ec67875273b31ac1e6823a6046fc931 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 25 Sep 2023 18:56:26 +0300 Subject: [PATCH 765/984] Simplify Minecraft Server SRV handling (#100726) --- .../components/minecraft_server/__init__.py | 67 ++++++-- .../minecraft_server/config_flow.py | 83 ++++----- .../components/minecraft_server/const.py | 1 - .../minecraft_server/coordinator.py | 43 ++--- .../components/minecraft_server/helpers.py | 38 ----- .../components/minecraft_server/manifest.json | 2 +- .../components/minecraft_server/strings.json | 5 +- requirements_all.txt | 1 - requirements_test_all.txt | 1 - tests/components/minecraft_server/const.py | 2 + .../minecraft_server/test_config_flow.py | 148 +++------------- .../components/minecraft_server/test_init.py | 160 ++++++++++++++---- 12 files changed, 245 insertions(+), 306 deletions(-) delete mode 100644 homeassistant/components/minecraft_server/helpers.py diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index b7326735be9..7f2b08c96ef 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -4,8 +4,10 @@ from __future__ import annotations import logging from typing import Any +from mcstatus import JavaServer + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er @@ -20,20 +22,14 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - _LOGGER.debug( - "Creating coordinator instance for '%s' (%s)", - entry.data[CONF_NAME], - entry.data[CONF_HOST], - ) # Create coordinator instance. - config_entry_id = entry.entry_id - coordinator = MinecraftServerCoordinator(hass, config_entry_id, entry.data) + coordinator = MinecraftServerCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() # Store coordinator instance. domain_data = hass.data.setdefault(DOMAIN, {}) - domain_data[config_entry_id] = coordinator + domain_data[entry.entry_id] = coordinator # Set up platforms. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -43,7 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Minecraft Server config entry.""" - config_entry_id = config_entry.entry_id # Unload platforms. unload_ok = await hass.config_entries.async_unload_platforms( @@ -51,17 +46,18 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) # Clean up. - hass.data[DOMAIN].pop(config_entry_id) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old config entry to a new format.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) # 1 --> 2: Use config entry ID as base for unique IDs. if config_entry.version == 1: + _LOGGER.debug("Migrating from version 1") + old_unique_id = config_entry.unique_id assert old_unique_id config_entry_id = config_entry.entry_id @@ -78,7 +74,52 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate entities. await er.async_migrate_entries(hass, config_entry_id, _migrate_entity_unique_id) - _LOGGER.debug("Migration to version %s successful", config_entry.version) + _LOGGER.debug("Migration to version 2 successful") + + # 2 --> 3: Use address instead of host and port in config entry. + if config_entry.version == 2: + _LOGGER.debug("Migrating from version 2") + + config_data = config_entry.data + + # Migrate config entry. + try: + address = config_data[CONF_HOST] + JavaServer.lookup(address) + host_only_lookup_success = True + except ValueError as error: + host_only_lookup_success = False + _LOGGER.debug( + "Hostname (without port) cannot be parsed (error: %s), trying again with port", + error, + ) + + if not host_only_lookup_success: + try: + address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" + JavaServer.lookup(address) + except ValueError as error: + _LOGGER.exception( + "Can't migrate configuration entry due to error while parsing server address (error: %s), try again later", + error, + ) + return False + + _LOGGER.debug( + "Migrating config entry, replacing host '%s' and port '%s' with address '%s'", + config_data[CONF_HOST], + config_data[CONF_PORT], + address, + ) + + new_data = config_data.copy() + new_data[CONF_ADDRESS] = address + del new_data[CONF_HOST] + del new_data[CONF_PORT] + config_entry.version = 3 + hass.config_entries.async_update_entry(config_entry, data=new_data) + + _LOGGER.debug("Migration to version 3 successful") return True diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index f4b4212bc64..527dfa1ed04 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,18 +1,16 @@ """Config flow for Minecraft Server integration.""" -from contextlib import suppress import logging from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.data_entry_flow import FlowResult -from . import helpers -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DOMAIN -DEFAULT_HOST = "localhost:25565" +DEFAULT_ADDRESS = "localhost:25565" _LOGGER = logging.getLogger(__name__) @@ -20,51 +18,22 @@ _LOGGER = logging.getLogger(__name__) class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" - VERSION = 2 + VERSION = 3 async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" errors = {} - if user_input is not None: - host = None - port = DEFAULT_PORT - title = user_input[CONF_HOST] + if user_input: + address = user_input[CONF_ADDRESS] - # Split address at last occurrence of ':'. - address_left, separator, address_right = user_input[CONF_HOST].rpartition( - ":" - ) + if await self._async_is_server_online(address): + # No error was detected, create configuration entry. + config_data = {CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address} + return self.async_create_entry(title=address, data=config_data) - # If no separator is found, 'rpartition' returns ('', '', original_string). - if separator == "": - host = address_right - else: - host = address_left - with suppress(ValueError): - port = int(address_right) - - # Remove '[' and ']' in case of an IPv6 address. - host = host.strip("[]") - - # Validate port configuration (limit to user and dynamic port range). - if (port < 1024) or (port > 65535): - errors["base"] = "invalid_port" - # Validate host and port by checking the server connection. - else: - # Create server instance with configuration data and ping the server. - config_data = { - CONF_NAME: user_input[CONF_NAME], - CONF_HOST: host, - CONF_PORT: port, - } - if await self._async_is_server_online(host, port): - # Configuration data are available and no error was detected, - # create configuration entry. - return self.async_create_entry(title=title, data=config_data) - - # Host or port invalid or server not reachable. - errors["base"] = "cannot_connect" + # Host or port invalid or server not reachable. + errors["base"] = "cannot_connect" # Show configuration form (default form in case of no user_input, # form filled with user_input and eventually with errors otherwise). @@ -83,24 +52,32 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) ): str, vol.Required( - CONF_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST) + CONF_ADDRESS, + default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS), ): vol.All(str, vol.Lower), } ), errors=errors, ) - async def _async_is_server_online(self, host: str, port: int) -> bool: + async def _async_is_server_online(self, address: str) -> bool: """Check server connection using a 'status' request and return result.""" - # Check if host is a SRV record. If so, update server data. - if srv_record := await helpers.async_check_srv_record(host): - # Use extracted host and port from SRV record. - host = srv_record[CONF_HOST] - port = srv_record[CONF_PORT] + # Parse and check server address. + try: + server = await JavaServer.async_lookup(address) + except ValueError as error: + _LOGGER.debug( + ( + "Error occurred while parsing server address '%s' -" + " ValueError: %s" + ), + address, + error, + ) + return False # Send a status request to the server. - server = JavaServer(host, port) try: await server.async_status() return True @@ -110,8 +87,8 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): "Error occurred while trying to check the connection to '%s:%s' -" " OSError: %s" ), - host, - port, + server.address.host, + server.address.port, error, ) diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 9f14f429a12..e7a58741696 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,7 +1,6 @@ """Constants for the Minecraft Server integration.""" DEFAULT_NAME = "Minecraft Server" -DEFAULT_PORT = 25565 DOMAIN = "minecraft_server" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 178c12772c6..9b5ab1fbb43 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -1,20 +1,18 @@ """The Minecraft Server integration.""" from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any from mcstatus.server import JavaServer -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import helpers - SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -36,12 +34,11 @@ class MinecraftServerData: class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - _srv_record_checked = False - - def __init__( - self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] - ) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize coordinator instance.""" + config_data = config_entry.data + self.unique_id = config_entry.entry_id + super().__init__( hass=hass, name=config_data[CONF_NAME], @@ -49,34 +46,20 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): update_interval=SCAN_INTERVAL, ) - # Server data - self.unique_id = unique_id - self._host = config_data[CONF_HOST] - self._port = config_data[CONF_PORT] - - # 3rd party library instance - self._server = JavaServer(self._host, self._port) + try: + self._server = JavaServer.lookup(config_data[CONF_ADDRESS]) + except ValueError as error: + raise HomeAssistantError( + f"Address in configuration entry cannot be parsed (error: {error}), please remove this device and add it again" + ) from error async def _async_update_data(self) -> MinecraftServerData: """Get server data from 3rd party library and update properties.""" - - # Check once if host is a valid Minecraft SRV record. - if not self._srv_record_checked: - self._srv_record_checked = True - if srv_record := await helpers.async_check_srv_record(self._host): - # Overwrite host, port and 3rd party library instance - # with data extracted out of the SRV record. - self._host = srv_record[CONF_HOST] - self._port = srv_record[CONF_PORT] - self._server = JavaServer(self._host, self._port) - - # Send status request to the server. try: status_response = await self._server.async_status() except OSError as error: raise UpdateFailed(error) from error - # Got answer to request, update properties. players_list = [] if players := status_response.players.sample: for player in players: diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py deleted file mode 100644 index f5991620c68..00000000000 --- a/homeassistant/components/minecraft_server/helpers.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Helper functions of Minecraft Server integration.""" -import logging -from typing import Any - -import aiodns - -from homeassistant.const import CONF_HOST, CONF_PORT - -SRV_RECORD_PREFIX = "_minecraft._tcp" - -_LOGGER = logging.getLogger(__name__) - - -async def async_check_srv_record(host: str) -> dict[str, Any] | None: - """Check if the given host is a valid Minecraft SRV record.""" - srv_record = None - - try: - srv_query = await aiodns.DNSResolver().query( - host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" - ) - except aiodns.error.DNSError: - # 'host' is not a Minecraft SRV record. - pass - else: - # 'host' is a valid Minecraft SRV record, extract the data. - srv_record = { - CONF_HOST: srv_query[0].host, - CONF_PORT: srv_query[0].port, - } - _LOGGER.debug( - "'%s' is a valid Minecraft SRV record ('%s:%s')", - host, - srv_record[CONF_HOST], - srv_record[CONF_PORT], - ) - - return srv_record diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 758f22b1e9a..6f11d34cccb 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "silver", - "requirements": ["aiodns==3.0.0", "mcstatus==11.0.0"] + "requirements": ["mcstatus==11.0.0"] } diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index b64c96f580b..c5fe5b81d81 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -6,13 +6,12 @@ "description": "Set up your Minecraft Server instance to allow monitoring.", "data": { "name": "[%key:common::config_flow::data::name%]", - "host": "[%key:common::config_flow::data::host%]" + "address": "Server address" } } }, "error": { - "invalid_port": "Port must be in range from 1024 to 65535. Please correct it and try again.", - "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server." + "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. Also ensure that you are running at least version 1.7 of Minecraft Java Edition on your server." } }, "entity": { diff --git a/requirements_all.txt b/requirements_all.txt index 05844f2ba20..5b4ab7b3a16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,6 @@ aiocomelit==0.0.8 aiodiscover==1.5.1 # homeassistant.components.dnsip -# homeassistant.components.minecraft_server aiodns==3.0.0 # homeassistant.components.eafm diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3f898342af..2885c93c0fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,7 +197,6 @@ aiocomelit==0.0.8 aiodiscover==1.5.1 # homeassistant.components.dnsip -# homeassistant.components.minecraft_server aiodns==3.0.0 # homeassistant.components.eafm diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index 3f635fbe333..c7eb0e4b096 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -7,6 +7,8 @@ from mcstatus.status_response import ( ) TEST_HOST = "mc.dummyserver.com" +TEST_PORT = 25566 +TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" TEST_JAVA_STATUS_RESPONSE_RAW = { "description": {"text": "Dummy Description"}, diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 463a78b4680..88afa6576d5 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -1,59 +1,20 @@ """Tests for the Minecraft Server config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch -import aiodns +from mcstatus import JavaServer -from homeassistant.components.minecraft_server.const import ( - DEFAULT_NAME, - DEFAULT_PORT, - DOMAIN, -) +from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE - - -class QueryMock: - """Mock for result of aiodns.DNSResolver.query.""" - - def __init__(self) -> None: - """Set up query result mock.""" - self.host = TEST_HOST - self.port = 23456 - self.priority = 1 - self.weight = 1 - self.ttl = None - +from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT USER_INPUT = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"{TEST_HOST}:{DEFAULT_PORT}", -} - -USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST} - -USER_INPUT_IPV4 = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"1.1.1.1:{DEFAULT_PORT}", -} - -USER_INPUT_IPV6 = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"[::ffff:0101:0101]:{DEFAULT_PORT}", -} - -USER_INPUT_PORT_TOO_SMALL = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"{TEST_HOST}:1023", -} - -USER_INPUT_PORT_TOO_LARGE = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"{TEST_HOST}:65536", + CONF_ADDRESS: TEST_ADDRESS, } @@ -67,39 +28,25 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_port_too_small(hass: HomeAssistant) -> None: - """Test error in case of a too small port.""" +async def test_lookup_failed(hass: HomeAssistant) -> None: + """Test error in case of a failed connection.""" with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.async_lookup", + side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_SMALL + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_port"} - - -async def test_port_too_large(hass: HomeAssistant) -> None: - """Test error in case of a too large port.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_LARGE - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_port"} + assert result["errors"] == {"base": "cannot_connect"} async def test_connection_failed(hass: HomeAssistant) -> None: """Test error in case of a failed connection.""" with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch("mcstatus.server.JavaServer.async_status", side_effect=OSError): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -109,30 +56,11 @@ async def test_connection_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with a SRV record.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=AsyncMock(return_value=[QueryMock()]), - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_SRV - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_SRV[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_SRV[CONF_NAME] - assert result["data"][CONF_HOST] == USER_INPUT_SRV[CONF_HOST] - - -async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: +async def test_connection_succeeded(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with a host name.""" with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "mcstatus.server.JavaServer.async_status", return_value=TEST_JAVA_STATUS_RESPONSE, @@ -142,44 +70,6 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT[CONF_HOST] + assert result["title"] == USER_INPUT[CONF_ADDRESS] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] - assert result["data"][CONF_HOST] == TEST_HOST - - -async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with an IPv4 address.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_IPV4[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_IPV4[CONF_NAME] - assert result["data"][CONF_HOST] == "1.1.1.1" - - -async def test_connection_succeeded_with_ip6(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with an IPv6 address.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6 - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == USER_INPUT_IPV6[CONF_HOST] - assert result["data"][CONF_NAME] == USER_INPUT_IPV6[CONF_NAME] - assert result["data"][CONF_HOST] == "::ffff:0101:0101" + assert result["data"][CONF_ADDRESS] == TEST_ADDRESS diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 77b6901a0a2..1e3679fb1e3 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -1,24 +1,20 @@ """Tests for the Minecraft Server integration.""" from unittest.mock import patch -import aiodns +from mcstatus import JavaServer from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.minecraft_server.const import ( - DEFAULT_NAME, - DEFAULT_PORT, - DOMAIN, -) +from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE +from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT from tests.common import MockConfigEntry -TEST_UNIQUE_ID = f"{TEST_HOST}-{DEFAULT_PORT}" +TEST_UNIQUE_ID = f"{TEST_HOST}-{TEST_PORT}" SENSOR_KEYS = [ {"v1": "Latency Time", "v2": "latency"}, @@ -32,43 +28,54 @@ SENSOR_KEYS = [ BINARY_SENSOR_KEYS = {"v1": "Status", "v2": "status"} -async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: - """Test entry migration from version 1 to 2.""" - - # Create mock config entry. +def create_v1_mock_config_entry(hass: HomeAssistant) -> int: + """Create mock config entry.""" config_entry_v1 = MockConfigEntry( domain=DOMAIN, unique_id=TEST_UNIQUE_ID, data={ CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST, - CONF_PORT: DEFAULT_PORT, + CONF_PORT: TEST_PORT, }, version=1, ) config_entry_id = config_entry_v1.entry_id config_entry_v1.add_to_hass(hass) - # Create mock device entry. + return config_entry_id + + +def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: int) -> int: + """Create mock device entry.""" device_registry = dr.async_get(hass) device_entry_v1 = device_registry.async_get_or_create( config_entry_id=config_entry_id, identifiers={(DOMAIN, TEST_UNIQUE_ID)}, ) device_entry_id = device_entry_v1.id + assert device_entry_v1 assert device_entry_id - # Create mock sensor entity entries. + return device_entry_id + + +def create_v1_mock_sensor_entity_entries( + hass: HomeAssistant, config_entry_id: int, device_entry_id: int +) -> list[dict]: + """Create mock sensor entity entries.""" sensor_entity_id_key_mapping_list = [] + config_entry = hass.config_entries.async_get_entry(config_entry_id) entity_registry = er.async_get(hass) + for sensor_key in SENSOR_KEYS: entity_unique_id = f"{TEST_UNIQUE_ID}-{sensor_key['v1']}" entity_entry_v1 = entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, unique_id=entity_unique_id, - config_entry=config_entry_v1, + config_entry=config_entry, device_id=device_entry_id, ) assert entity_entry_v1.unique_id == entity_unique_id @@ -76,25 +83,51 @@ async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: {"entity_id": entity_entry_v1.entity_id, "key": sensor_key["v2"]} ) - # Create mock binary sensor entity entry. + return sensor_entity_id_key_mapping_list + + +def create_v1_mock_binary_sensor_entity_entry( + hass: HomeAssistant, config_entry_id: int, device_entry_id: int +) -> dict: + """Create mock binary sensor entity entry.""" + config_entry = hass.config_entries.async_get_entry(config_entry_id) + entity_registry = er.async_get(hass) entity_unique_id = f"{TEST_UNIQUE_ID}-{BINARY_SENSOR_KEYS['v1']}" - entity_entry_v1 = entity_registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( BINARY_SENSOR_DOMAIN, DOMAIN, unique_id=entity_unique_id, - config_entry=config_entry_v1, + config_entry=config_entry, device_id=device_entry_id, ) - assert entity_entry_v1.unique_id == entity_unique_id + assert entity_entry.unique_id == entity_unique_id binary_sensor_entity_id_key_mapping = { - "entity_id": entity_entry_v1.entity_id, + "entity_id": entity_entry.entity_id, "key": BINARY_SENSOR_KEYS["v2"], } + return binary_sensor_entity_id_key_mapping + + +async def test_entry_migration(hass: HomeAssistant) -> None: + """Test entry migration from version 1 to 3, where host and port is required for the connection to the server.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + sensor_entity_id_key_mapping_list = create_v1_mock_sensor_entity_entries( + hass, config_entry_id, device_entry_id + ) + binary_sensor_entity_id_key_mapping = create_v1_mock_binary_sensor_entity_entry( + hass, config_entry_id, device_entry_id + ) + # Trigger migration. with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, + "mcstatus.server.JavaServer.lookup", + side_effect=[ + ValueError, + JavaServer(host=TEST_HOST, port=TEST_PORT), + JavaServer(host=TEST_HOST, port=TEST_PORT), + ], ), patch( "mcstatus.server.JavaServer.async_status", return_value=TEST_JAVA_STATUS_RESPONSE, @@ -103,29 +136,84 @@ async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Test migrated config entry. - config_entry_v2 = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry_v2.unique_id is None - assert config_entry_v2.data == { + config_entry = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.unique_id is None + assert config_entry.data == { CONF_NAME: DEFAULT_NAME, - CONF_HOST: TEST_HOST, - CONF_PORT: DEFAULT_PORT, + CONF_ADDRESS: TEST_ADDRESS, } - assert config_entry_v2.version == 2 + assert config_entry.version == 3 # Test migrated device entry. - device_entry_v2 = device_registry.async_get(device_entry_id) - assert device_entry_v2.identifiers == {(DOMAIN, config_entry_id)} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_entry_id) + assert device_entry.identifiers == {(DOMAIN, config_entry_id)} # Test migrated sensor entity entries. + entity_registry = er.async_get(hass) for mapping in sensor_entity_id_key_mapping_list: - entity_entry_v2 = entity_registry.async_get(mapping["entity_id"]) - assert entity_entry_v2.unique_id == f"{config_entry_id}-{mapping['key']}" + entity_entry = entity_registry.async_get(mapping["entity_id"]) + assert entity_entry.unique_id == f"{config_entry_id}-{mapping['key']}" # Test migrated binary sensor entity entry. - entity_entry_v2 = entity_registry.async_get( + entity_entry = entity_registry.async_get( binary_sensor_entity_id_key_mapping["entity_id"] ) assert ( - entity_entry_v2.unique_id + entity_entry.unique_id == f"{config_entry_id}-{binary_sensor_entity_id_key_mapping['key']}" ) + + +async def test_entry_migration_host_only(hass: HomeAssistant) -> None: + """Test entry migration from version 1 to 3, where host alone is sufficient for the connection to the server.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) + create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + + # Trigger migration. + with patch( + "mcstatus.server.JavaServer.lookup", + side_effect=[ + JavaServer(host=TEST_HOST, port=TEST_PORT), + JavaServer(host=TEST_HOST, port=TEST_PORT), + ], + ), patch( + "mcstatus.server.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ): + assert await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + + # Test migrated config entry. + config_entry = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.unique_id is None + assert config_entry.data == { + CONF_NAME: DEFAULT_NAME, + CONF_ADDRESS: TEST_HOST, + } + assert config_entry.version == 3 + + +async def test_entry_migration_v3_failure(hass: HomeAssistant) -> None: + """Test failed entry migration from version 2 to 3.""" + config_entry_id = create_v1_mock_config_entry(hass) + device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) + create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + + # Trigger migration. + with patch( + "mcstatus.server.JavaServer.lookup", + side_effect=[ + ValueError, + ValueError, + ], + ): + assert not await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + + # Test config entry. + config_entry = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry.version == 2 From 3da4815522b8909d4103a24397a21b188fcea0d1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 17:59:33 +0200 Subject: [PATCH 766/984] Avoid redundant calls to async_write_ha_state for mqtt fan (#100777) Avoid redundant calls to async_write_ha_state --- homeassistant/components/mqtt/fan.py | 21 +++++++------ tests/components/mqtt/test_fan.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 5c7557c7598..5375fa5afc2 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -50,7 +50,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -59,7 +64,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" @@ -367,6 +372,7 @@ class MqttFan(MqttEntity, FanEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" payload = self._value_templates[CONF_STATE](msg.payload) @@ -379,12 +385,12 @@ class MqttFan(MqttEntity, FanEntity): self._attr_is_on = False elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_percentage"}) def percentage_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the percentage.""" rendered_percentage_payload = self._value_templates[ATTR_PERCENTAGE]( @@ -395,7 +401,6 @@ class MqttFan(MqttEntity, FanEntity): return if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: self._attr_percentage = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: percentage = ranged_value_to_percentage( @@ -424,18 +429,17 @@ class MqttFan(MqttEntity, FanEntity): ) return self._attr_percentage = percentage - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_PERCENTAGE_STATE_TOPIC, percentage_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_preset_mode"}) def preset_mode_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for preset mode.""" preset_mode = str(self._value_templates[ATTR_PRESET_MODE](msg.payload)) if preset_mode == self._payload["PRESET_MODE_RESET"]: self._attr_preset_mode = None - self.async_write_ha_state() return if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) @@ -450,12 +454,12 @@ class MqttFan(MqttEntity, FanEntity): return self._attr_preset_mode = preset_mode - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_PRESET_MODE_STATE_TOPIC, preset_mode_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_oscillating"}) def oscillation_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the oscillation.""" payload = self._value_templates[ATTR_OSCILLATING](msg.payload) @@ -466,13 +470,13 @@ class MqttFan(MqttEntity, FanEntity): self._attr_oscillating = True elif payload == self._payload["OSCILLATE_OFF_PAYLOAD"]: self._attr_oscillating = False - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if add_subscribe_topic(CONF_OSCILLATION_STATE_TOPIC, oscillation_received): self._attr_oscillating = False @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_direction"}) def direction_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the direction.""" direction = self._value_templates[ATTR_DIRECTION](msg.payload) @@ -480,7 +484,6 @@ class MqttFan(MqttEntity, FanEntity): _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) return self._attr_current_direction = str(direction) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscribe_topic(CONF_DIRECTION_STATE_TOPIC, direction_received) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 803a0d74766..fe354817aef 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -60,6 +60,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -2244,3 +2245,48 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + fan.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "direction_state_topic": "direction-state-topic", + "percentage_state_topic": "percentage-state-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_modes": ["eco", "silent"], + "oscillation_state_topic": "oscillation-state-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "ON", "OFF"), + ("direction-state-topic", "forward", "reverse"), + ("percentage-state-topic", "30", "40"), + ("preset-mode-state-topic", "eco", "silent"), + ("oscillation-state-topic", "oscillate_on", "oscillate_off"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From f83a5976032d1801c51f4328427739916f642e21 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:00:08 +0200 Subject: [PATCH 767/984] Avoid redundant calls to async_write_ha_state in mqtt humidifier (#100781) Avoid redundant calls to async_write_ha_state --- homeassistant/components/mqtt/humidifier.py | 22 ++++----- tests/components/mqtt/test_humidifier.py | 50 +++++++++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 52d8db3fc98..1742a768ffb 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -52,7 +52,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -60,7 +65,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic CONF_AVAILABLE_MODES_LIST = "modes" CONF_DEVICE_CLASS = "device_class" @@ -313,6 +318,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" payload = self._value_templates[CONF_STATE](msg.payload) @@ -325,12 +331,12 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_is_on = False elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_action"}) def action_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" action_payload = self._value_templates[ATTR_ACTION](msg.payload) @@ -347,12 +353,12 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): action_payload, ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_ACTION_TOPIC, action_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_humidity"}) def current_humidity_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the current humidity.""" rendered_current_humidity_payload = self._value_templates[ @@ -360,7 +366,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): ](msg.payload) if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: self._attr_current_humidity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not rendered_current_humidity_payload: _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) @@ -384,7 +389,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): ) return self._attr_current_humidity = current_humidity - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription( topics, CONF_CURRENT_HUMIDITY_TOPIC, current_humidity_received @@ -392,6 +396,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_target_humidity"}) def target_humidity_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for the target humidity.""" rendered_target_humidity_payload = self._value_templates[ATTR_HUMIDITY]( @@ -402,7 +407,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): return if rendered_target_humidity_payload == self._payload["HUMIDITY_RESET"]: self._attr_target_humidity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: target_humidity = round(float(rendered_target_humidity_payload)) @@ -426,7 +430,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): ) return self._attr_target_humidity = target_humidity - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription( topics, CONF_TARGET_HUMIDITY_STATE_TOPIC, target_humidity_received @@ -434,12 +437,12 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_mode"}) def mode_received(msg: ReceiveMessage) -> None: """Handle new received MQTT message for mode.""" mode = str(self._value_templates[ATTR_MODE](msg.payload)) if mode == self._payload["MODE_RESET"]: self._attr_mode = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if not mode: _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic) @@ -454,7 +457,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): return self._attr_mode = mode - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self.add_subscription(topics, CONF_MODE_STATE_TOPIC, mode_received) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 0cc4d936841..4d2637a264f 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -38,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -60,6 +61,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1569,3 +1571,51 @@ async def test_unload_config_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + humidifier.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "action_topic": "action-topic", + "target_humidity_state_topic": "target-humidity-state-topic", + "current_humidity_topic": "current-humidity-topic", + "mode_command_topic": "mode-command-topic", + "mode_state_topic": "mode-state-topic", + "modes": [ + "comfort", + "eco", + ], + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "ON", "OFF"), + ("action-topic", "idle", "humidifying"), + ("current-humidity-topic", "31", "32"), + ("target-humidity-state-topic", "30", "40"), + ("mode-state-topic", "comfort", "eco"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 002be37257fb5cffadd8e9fa3e7cde9af6055798 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:02:17 +0200 Subject: [PATCH 768/984] Rework and added tests for mqtt event (#100769) Use write_state_on_attr_change and add tests --- homeassistant/components/mqtt/event.py | 10 ++-- tests/components/mqtt/test_event.py | 67 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 6f8be33f21a..6fe39b5e899 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -32,14 +32,18 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttValueTemplate, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -133,6 +137,7 @@ class MqttEvent(MqttEntity, EventEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"state"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" event_attributes: dict[str, Any] = {} @@ -195,7 +200,6 @@ class MqttEvent(MqttEntity, EventEntity): payload, ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index abcd6e8f3ee..401caac8007 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -42,6 +43,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -668,3 +670,68 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + event.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_skipped_async_ha_write_state2( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test a write state command is only called when there is a valid event.""" + await mqtt_mock_entry() + topic = "test-topic" + payload1 = '{"event_type": "press"}' + payload2 = '{"event_type": "unknown"}' + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + assert len(mock_async_ha_write_state.mock_calls) == 0 + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 From 014fb617437f623e283e817888942a3fa29ee6a0 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 25 Sep 2023 17:03:11 +0100 Subject: [PATCH 769/984] Fix missing device class on Velux Windows (#100863) --- homeassistant/components/homekit_controller/cover.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 73eb699007c..0f4af988c14 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -299,8 +299,14 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): return {"obstruction-detected": obstruction_detected} +class HomeKitWindow(HomeKitWindowCover): + """Representation of a HomeKit Window.""" + + _attr_device_class = CoverDeviceClass.WINDOW + + ENTITY_TYPES = { ServicesTypes.GARAGE_DOOR_OPENER: HomeKitGarageDoorCover, ServicesTypes.WINDOW_COVERING: HomeKitWindowCover, - ServicesTypes.WINDOW: HomeKitWindowCover, + ServicesTypes.WINDOW: HomeKitWindow, } From ce02cbefc9b4d26438d79fcf66e2e41e6c591cc1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:03:52 +0200 Subject: [PATCH 770/984] Avoid redundant calls to async_write_ha_state in mqtt lawn_mower (#100795) Avoid redundant calls to async_write_ha_state --- homeassistant/components/mqtt/lawn_mower.py | 12 ++++--- tests/components/mqtt/test_lawn_mower.py | 38 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index fc3996ffbff..42761d224f8 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -32,7 +32,12 @@ from .const import ( DEFAULT_RETAIN, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -40,7 +45,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -168,6 +173,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_activity"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload = str(self._value_template(msg.payload)) @@ -180,7 +186,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): return if payload.lower() == "none": self._attr_activity = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return try: @@ -193,7 +198,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): [option.value for option in LawnMowerActivity], ) return - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: # Force into optimistic mode. diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index b7130cac3bf..85df2caef6c 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -47,6 +48,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -886,3 +888,39 @@ async def test_persistent_state_after_reconfig( # assert the state persistent state = hass.states.get("lawn_mower.garden") assert state.state == "docked" + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ( + { + "activity_state_topic": "activity-state-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("activity-state-topic", "mowing", "paused"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 98cc2e8098113b39848f0d3d994beaebc187bafb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:04:33 +0200 Subject: [PATCH 771/984] Avoid redundant calls to async_write_ha_state in mqtt lock (#100802) Avoid redundant calls to async_write_ha_state --- homeassistant/components/mqtt/lock.py | 19 +++++++++++--- tests/components/mqtt/test_lock.py | 38 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index d2e67ba40da..ebc4eced9c2 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -33,7 +33,12 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -41,7 +46,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data CONF_CODE_FORMAT = "code_format" @@ -190,6 +194,15 @@ class MqttLock(MqttEntity, LockEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_is_jammed", + "_attr_is_locked", + "_attr_is_locking", + "_attr_is_unlocking", + }, + ) def message_received(msg: ReceiveMessage) -> None: """Handle new lock state messages.""" payload = self._value_template(msg.payload) @@ -199,8 +212,6 @@ class MqttLock(MqttEntity, LockEntity): self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index bf7e1529a4e..f9a33c211ee 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -50,6 +50,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1030,3 +1031,40 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("state-topic", "closed", "open"), + ("state-topic", "closed", "opening"), + ("state-topic", "open", "closing"), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 180f2483708042ea8177f8ddf90b77fe03c6cf4f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:05:14 +0200 Subject: [PATCH 772/984] Avoid redundant calls to async_write_ha_state in mqtt number (#100808) Avoid redundant calls to async_write_ha_state --- homeassistant/components/mqtt/number.py | 10 +++++-- tests/components/mqtt/test_number.py | 38 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index a88210a3198..5ca0340ec30 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -42,7 +42,12 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -50,7 +55,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -183,6 +187,7 @@ class MqttNumber(MqttEntity, RestoreNumber): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_native_value"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" num_value: int | float | None @@ -214,7 +219,6 @@ class MqttNumber(MqttEntity, RestoreNumber): return self._attr_native_value = num_value - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index dbdd373a659..c6590c71c4d 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -31,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -54,6 +55,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1140,3 +1142,39 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + number.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "10", "20.7"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 33d45b34546793b737b306e7af65760630ef1785 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:05:42 +0200 Subject: [PATCH 773/984] Avoid redundant calls to async_write_ha_state in mqtt select (#100809) Avoid redundant calls to async_write_ha_state --- homeassistant/components/mqtt/select.py | 11 ++++--- tests/components/mqtt/test_select.py | 38 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 1c4b33de0ee..7982e075567 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -28,7 +28,12 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -36,7 +41,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -131,12 +135,12 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_current_option"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" payload = str(self._value_template(msg.payload)) if payload.lower() == "none": self._attr_current_option = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) return if payload not in self.options: @@ -148,7 +152,6 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): ) return self._attr_current_option = payload - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index f1903fa4c3c..0c18881d86e 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -25,6 +25,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers.typing import ConfigType from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -48,6 +49,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -810,3 +812,39 @@ async def test_persistent_state_after_reconfig( state = hass.states.get("select.milk") assert state.state == "beer" assert state.attributes["options"] == ["beer"] + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + select.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "milk", "beer"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 8d10cdce4e1551aef3a3ff21aba56ff67358dc5d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:06:19 +0200 Subject: [PATCH 774/984] Avoid redundant calls to async_ha_write_state in mqtt switch (#100815) Avoid redundant calls to async_ha_write_state --- homeassistant/components/mqtt/switch.py | 11 ++++--- tests/components/mqtt/test_switch.py | 38 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index e8872d3f0d1..125998e5955 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -37,9 +37,13 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data DEFAULT_NAME = "MQTT Switch" DEFAULT_PAYLOAD_ON = "ON" @@ -136,6 +140,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" payload = self._value_template(msg.payload) @@ -146,8 +151,6 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): elif payload == PAYLOAD_NONE: self._attr_is_on = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 4471cc7dc11..32195289aab 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -17,6 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -39,6 +40,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -762,3 +764,39 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + switch.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "ON", "OFF"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From cd3d3b76a385b040a59e9fd346a836cd875359e0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:07:24 +0200 Subject: [PATCH 775/984] Avoid redundant calls to async_ha_write_state in mqtt text (#100816) Avoid redundant calls to async_ha_write_state --- homeassistant/components/mqtt/text.py | 10 ++++--- tests/components/mqtt/test_text.py | 38 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 6d1196cfd95..e6755c653f3 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -35,7 +35,12 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -44,7 +49,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -188,11 +192,11 @@ class MqttTextEntity(MqttEntity, TextEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_native_value"}) def handle_state_message_received(msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" payload = str(self._value_template(msg.payload)) self._attr_native_value = payload - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 9e068a07824..bf6fe1b0130 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -16,6 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -38,6 +39,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -762,3 +764,39 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + text.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "My original text", "Changed text"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 30c7e7fbdf62039c4a4e11b18ff7b981fcc6407d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 18:08:02 +0200 Subject: [PATCH 776/984] Avoid redundant calls to async_ha_write_state mqtt update platform (#100819) Avoid redundant calls to async_ha_write_state --- homeassistant/components/mqtt/update.py | 30 +++++++++----- tests/components/mqtt/test_update.py | 54 ++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index f6db0d3fd64..cf3237c1b1c 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -33,9 +33,14 @@ from .const import ( PAYLOAD_EMPTY_JSON, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -171,6 +176,17 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_installed_version", + "_attr_latest_version", + "_attr_title", + "_attr_release_summary", + "_attr_release_url", + "_entity_picture", + }, + ) def handle_state_message_received(msg: ReceiveMessage) -> None: """Handle receiving state message via MQTT.""" payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) @@ -219,39 +235,33 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): if "installed_version" in json_payload: self._attr_installed_version = json_payload["installed_version"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "latest_version" in json_payload: self._attr_latest_version = json_payload["latest_version"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "title" in json_payload: self._attr_title = json_payload["title"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "release_summary" in json_payload: self._attr_release_summary = json_payload["release_summary"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "release_url" in json_payload: self._attr_release_url = json_payload["release_url"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if "entity_picture" in json_payload: self._entity_picture = json_payload["entity_picture"] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_latest_version"}) def handle_latest_version_received(msg: ReceiveMessage) -> None: """Handle receiving latest version via MQTT.""" latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) if isinstance(latest_version, str) and latest_version != "": self._attr_latest_version = latest_version - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) add_subscription( topics, CONF_LATEST_VERSION_TOPIC, handle_latest_version_received @@ -279,8 +289,6 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): self._config[CONF_ENCODING], ) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - @property def supported_features(self) -> UpdateEntityFeature: """Return the list of supported features.""" diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 9c881352f8c..c5fe5abd8c4 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -16,6 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -33,6 +34,7 @@ from .test_common import ( help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -47,7 +49,7 @@ DEFAULT_CONFIG = { update.DOMAIN: { "name": "test", "state_topic": "test-topic", - "latest_version_topic": "test-topic", + "latest_version_topic": "latest-version-topic", "command_topic": "test-topic", "payload_install": "install", } @@ -730,3 +732,53 @@ async def test_reloadable( domain = update.DOMAIN config = DEFAULT_CONFIG await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + update.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("latest-version-topic", "1.1", "1.2"), + ("test-topic", "1.1", "1.2"), + ("test-topic", '{"installed_version": "1.1"}', '{"installed_version": "1.2"}'), + ("test-topic", '{"latest_version": "1.1"}', '{"latest_version": "1.2"}'), + ("test-topic", '{"title": "Update"}', '{"title": "Patch"}'), + ("test-topic", '{"release_summary": "bla1"}', '{"release_summary": "bla2"}'), + ( + "test-topic", + '{"release_url": "https://example.com/update?r=1"}', + '{"release_url": "https://example.com/update?r=2"}', + ), + ( + "test-topic", + '{"entity_picture": "https://example.com/icon1.png"}', + '{"entity_picture": "https://example.com/icon2.png"}', + ), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 11e8bf0b9c2ea94a3cd089de7d71282ba4008ea3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 25 Sep 2023 18:53:22 +0200 Subject: [PATCH 777/984] Update types packages (#100850) --- .github/workflows/ci.yaml | 2 +- homeassistant/util/yaml/loader.py | 2 +- requirements_test.txt | 23 ++++++++++------------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2675a17e421..053877b608e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,7 +35,7 @@ on: env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 - MYPY_CACHE_VERSION: 4 + MYPY_CACHE_VERSION: 5 BLACK_CACHE_VERSION: 1 HA_SHORT_VERSION: "2023.10" DEFAULT_PYTHON: "3.11" diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 2e31b212f1f..5f18a729130 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -203,7 +203,7 @@ def _parse_yaml( # 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)) + yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] or NodeDictClass() ) diff --git a/requirements_test.txt b/requirements_test.txt index e9942a290bd..15404c159b9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -33,23 +33,20 @@ requests-mock==1.11.0 respx==0.20.2 syrupy==4.5.0 tqdm==4.66.1 -types-aiofiles==22.1.0 +types-aiofiles==23.2.0.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 -types-backports==0.1.3 types-beautifulsoup4==4.12.0.6 types-caldav==1.3.0.0 types-chardet==0.1.5 -types-decorator==5.1.8.3 -types-enum34==1.1.8 -types-ipaddress==1.0.8 -types-paho-mqtt==1.6.0.6 -types-Pillow==10.0.0.2 -types-pkg-resources==0.1.3 -types-psutil==5.9.5 -types-python-dateutil==2.8.19.13 +types-decorator==5.1.8.4 +types-paho-mqtt==1.6.0.7 +types-Pillow==10.0.0.3 +types-protobuf==4.24.0.2 +types-psutil==5.9.5.16 +types-python-dateutil==2.8.19.14 types-python-slugify==0.1.2 -types-pytz==2023.3.0.0 -types-PyYAML==6.0.12.2 -types-requests==2.31.0.1 +types-pytz==2023.3.1.1 +types-PyYAML==6.0.12.12 +types-requests==2.31.0.3 types-xmltodict==0.13.0.3 From d76c5ed351941038898e2c692d59108eda70b282 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 25 Sep 2023 18:58:10 +0200 Subject: [PATCH 778/984] Use wake word settings in assist pipeline runs (#100864) --- homeassistant/components/assist_pipeline/pipeline.py | 7 +++++-- homeassistant/components/wake_word/__init__.py | 2 +- tests/components/wake_word/test_init.py | 2 +- tests/components/wyoming/test_wake_word.py | 6 +++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 8e297b38797..e3b0eafda20 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -476,7 +476,9 @@ class PipelineRun: async def prepare_wake_word_detection(self) -> None: """Prepare wake-word-detection.""" - entity_id = wake_word.async_default_entity(self.hass) + entity_id = self.pipeline.wake_word_entity or wake_word.async_default_entity( + self.hass + ) if entity_id is None: raise WakeWordDetectionError( code="wake-engine-missing", @@ -553,7 +555,8 @@ class PipelineRun: audio_stream=stream, stt_audio_buffer=stt_audio_buffer, wake_word_vad=wake_word_vad, - ) + ), + self.pipeline.wake_word_id, ) if stt_audio_buffer is not None: diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 01344a00952..eeed7b8029b 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -96,7 +96,7 @@ class WakeWordDetectionEntity(RestoreEntity): """ async def async_process_audio_stream( - self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None = None + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps. diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index 4123e4a7e47..7f3e8f011ee 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -199,7 +199,7 @@ async def test_not_detected_entity( # Need 2 seconds to trigger state = setup.state - result = await setup.async_process_audio_stream(one_second_stream()) + result = await setup.async_process_audio_stream(one_second_stream(), None) assert result is None # State should only change when there's a detection diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index cd156c660a8..4ec471be7fd 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -54,7 +54,7 @@ async def test_streaming_audio( "homeassistant.components.wyoming.wake_word.AsyncTcpClient", MockAsyncTcpClient(client_events), ): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) assert result is not None assert result == snapshot @@ -78,7 +78,7 @@ async def test_streaming_audio_connection_lost( "homeassistant.components.wyoming.wake_word.AsyncTcpClient", MockAsyncTcpClient([None]), ): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) assert result is None @@ -103,6 +103,6 @@ async def test_streaming_audio_oserror( "homeassistant.components.wyoming.wake_word.AsyncTcpClient", mock_client, ), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")): - result = await entity.async_process_audio_stream(audio_stream()) + result = await entity.async_process_audio_stream(audio_stream(), None) assert result is None From ea1108503d1665f03fc210d5fa05077e6f86b2a0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 21:08:14 +0200 Subject: [PATCH 779/984] Rework and fix mqtt siren writing state and attributes (#100871) Rework mqtt siren writing state and attributes --- homeassistant/components/mqtt/siren.py | 10 ++++++---- tests/components/mqtt/test_siren.py | 11 ++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index aeabd0fe148..0d8a53e98ea 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -265,6 +265,7 @@ class MqttSiren(MqttEntity, SirenEntity): if json_payload[STATE] == PAYLOAD_NONE: self._attr_is_on = None del json_payload[STATE] + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if json_payload: # process attributes @@ -279,7 +280,7 @@ class MqttSiren(MqttEntity, SirenEntity): ) return self._update(process_turn_on_params(self, params)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. @@ -379,6 +380,7 @@ class MqttSiren(MqttEntity, SirenEntity): """Update the extra siren state attributes.""" for attribute, support in SUPPORTED_ATTRIBUTES.items(): if self._attr_supported_features & support and attribute in data: - self._extra_attributes[attribute] = data[ - attribute # type: ignore[literal-required] - ] + data_attr = data[attribute] # type: ignore[literal-required] + if self._extra_attributes.get(attribute) == data_attr: + continue + self._extra_attributes[attribute] = data_attr diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 7c448eba85e..4f703ff0023 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -257,7 +257,7 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa async_fire_mqtt_message( hass, "state-topic", - '{"state":"beer off", "duration": 5, "volume_level": 0.6}', + '{"state":"beer off", "tone": "bell", "duration": 5, "volume_level": 0.6}', ) state = hass.states.get("siren.test") @@ -270,14 +270,15 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa async_fire_mqtt_message( hass, "state-topic", - '{"state":"beer on", "duration": 6, "volume_level": 2 }', + '{"state":"beer on", "duration": 6, "volume_level": 2,"tone": "ping"}', ) state = hass.states.get("siren.test") assert ( - "Unable to update siren state attributes from payload '{'duration': 6, 'volume_level': 2}': value must be at most 1 for dictionary value @ data['volume_level']" + "Unable to update siren state attributes from payload '{'duration': 6, 'volume_level': 2, 'tone': 'ping'}': value must be at most 1 for dictionary value @ data['volume_level']" in caplog.text ) - assert state.state == STATE_OFF + # Only the on/of state was updated, not the attributes + assert state.state == STATE_ON assert state.attributes.get(siren.ATTR_TONE) == "bell" assert state.attributes.get(siren.ATTR_DURATION) == 5 assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 @@ -287,7 +288,7 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa "state-topic", "{}", ) - assert state.state == STATE_OFF + assert state.state == STATE_ON assert state.attributes.get(siren.ATTR_TONE) == "bell" assert state.attributes.get(siren.ATTR_DURATION) == 5 assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 From b9a3863645037844cee93e37dd81f2342ae12e0c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 25 Sep 2023 21:21:01 +0200 Subject: [PATCH 780/984] Handle json decode exception in co2signal (#100857) * Handle json decode exception in co2signal * Update homeassistant/components/co2signal/coordinator.py Co-authored-by: Joost Lekkerkerker * Fix import --------- Co-authored-by: Joost Lekkerkerker --- .../components/co2signal/coordinator.py | 5 ++++ .../components/co2signal/test_config_flow.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index dfb78326abe..c210d989c04 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping from datetime import timedelta +from json import JSONDecodeError import logging from typing import Any, cast @@ -68,6 +69,10 @@ def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalRespons wait=False, ) + except JSONDecodeError as err: + # raise occasional occurring json decoding errors as CO2Error so the data update coordinator retries it + raise CO2Error from err + except ValueError as err: err_str = str(err) diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index ddd2049800a..8f5c4cfd55c 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,4 +1,5 @@ """Test the CO2 Signal config flow.""" +from json import JSONDecodeError from unittest.mock import patch import pytest @@ -160,6 +161,28 @@ async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> No assert result2["errors"] == {"base": err_code} +async def test_form_invalid_json(hass: HomeAssistant) -> None: + """Test we handle invalid json.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "CO2Signal.get_latest", + side_effect=JSONDecodeError(msg="boom", doc="", pos=1), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + async def test_form_error_unexpected_error(hass: HomeAssistant) -> None: """Test we handle unexpected error.""" result = await hass.config_entries.flow.async_init( From 969d6b852eaa65995d2584dd1c9fac033e96160d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 14:34:02 -0500 Subject: [PATCH 781/984] Bump led-ble to 1.0.1 (#100873) --- homeassistant/components/led_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index d69b709c6be..7b936eaad1a 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.12.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.12.0", "led-ble==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5b4ab7b3a16..a4b2bac8c05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1126,7 +1126,7 @@ laundrify-aio==1.1.2 ld2410-ble==0.1.1 # homeassistant.components.led_ble -led-ble==1.0.0 +led-ble==1.0.1 # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2885c93c0fe..2e50fed5371 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -882,7 +882,7 @@ laundrify-aio==1.1.2 ld2410-ble==0.1.1 # homeassistant.components.led_ble -led-ble==1.0.0 +led-ble==1.0.1 # homeassistant.components.foscam libpyfoscam==1.0 From 7258bc6457d628e97eb2f66d65dc3314800b3eb0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 22:17:29 +0200 Subject: [PATCH 782/984] Avoid redundant calls to async_write_ha_state in mqtt vacuum (#100799) * Avoid redundant calls to async_write_ha_state * Add comment * Rephrase --- .../components/mqtt/vacuum/schema_legacy.py | 20 +++++++-- .../components/mqtt/vacuum/schema_state.py | 8 ++-- tests/components/mqtt/test_legacy_vacuum.py | 41 +++++++++++++++++++ tests/components/mqtt/test_state_vacuum.py | 38 +++++++++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 516a7772c11..478a91baaba 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -30,14 +30,14 @@ from .. import subscription from ..config import MQTT_BASE_SCHEMA from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ( MqttValueTemplate, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) -from ..util import get_mqtt_data, valid_publish_topic +from ..util import valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services @@ -313,6 +313,20 @@ class MqttVacuum(MqttEntity, VacuumEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_battery_level", + "_attr_fan_speed", + "_attr_is_on", + # We track _attr_status and _charging as they are used to + # To determine the batery_icon. + # We do not need to track _docked as it is + # not leading to entity changes directly. + "_attr_status", + "_charging", + }, + ) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT message.""" if ( @@ -387,8 +401,6 @@ class MqttVacuum(MqttEntity, VacuumEntity): if fan_speed and fan_speed is not PayloadSentinel.DEFAULT: self._attr_fan_speed = str(fan_speed) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - topics_list = {topic for topic in self._state_topics.values() if topic} self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 5113e19f097..425202adea2 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -38,9 +38,9 @@ from ..const import ( CONF_STATE_TOPIC, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change from ..models import ReceiveMessage -from ..util import get_mqtt_data, valid_publish_topic +from ..util import valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services @@ -231,6 +231,9 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, {"_attr_battery_level", "_attr_fan_speed", "_attr_state"} + ) def state_message_received(msg: ReceiveMessage) -> None: """Handle state MQTT message.""" payload = json_loads_object(msg.payload) @@ -242,7 +245,6 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): ) del payload[STATE] self._update_state_attributes(payload) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if state_topic := self._config.get(CONF_STATE_TOPIC): topics["state_position_topic"] = { diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 85e3bdd12b9..c7d17ed47a0 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -63,6 +63,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -1099,3 +1100,43 @@ async def test_setup_manual_entity_from_yaml( await mqtt_mock_entry() platform = vacuum.DOMAIN assert hass.states.get(f"{platform}.mqtttest") + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("vacuum/state", '{"battery_level": 71}', '{"battery_level": 60}'), + ("vacuum/state", '{"docked": true}', '{"docked": false}'), + ("vacuum/state", '{"cleaning": true}', '{"cleaning": false}'), + ("vacuum/state", '{"fan_speed": "max"}', '{"fan_speed": "min"}'), + ("vacuum/state", '{"error": "some error"}', '{"error": "other error"}'), + ("vacuum/state", '{"charging": true}', '{"charging": false}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index a24884941fc..40bd5158280 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -58,6 +58,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -821,3 +822,40 @@ async def test_setup_manual_entity_from_yaml( await mqtt_mock_entry() platform = vacuum.DOMAIN assert hass.states.get(f"{platform}.mqtttest") + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("vacuum/state", '{"state": "cleaning"}', '{"state": "docked"}'), + ("vacuum/state", '{"battery_level": 71}', '{"battery_level": 60}'), + ("vacuum/state", '{"fan_speed": "max"}', '{"fan_speed": "min"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From a242a1c10795c39fd3b01e1106ad8741581c10ee Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 22:20:32 +0200 Subject: [PATCH 783/984] Add tests for mqtt image (#100793) * Rework mqtt image writing state * Revert mixin changes, add attr * Revert code changes --- tests/components/mqtt/test_image.py | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index d5789880f73..621be984b7b 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -15,6 +15,7 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -34,6 +35,7 @@ from .test_common import ( help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -810,3 +812,37 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + image.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 2eefd21dccc55e841d36c4215e7851189a474f38 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 25 Sep 2023 22:21:40 +0200 Subject: [PATCH 784/984] Parametrize more co2signal config flow tests (#100882) * Clean up co2signal tests * Some more clean up * Use named parameter ids of parametrize --- .../components/co2signal/test_config_flow.py | 102 +++++++----------- 1 file changed, 37 insertions(+), 65 deletions(-) diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 8f5c4cfd55c..879293ae959 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -1,6 +1,6 @@ """Test the CO2 Signal config flow.""" from json import JSONDecodeError -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -132,14 +132,33 @@ async def test_form_country(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("err_str", "err_code"), + ("side_effect", "err_code"), [ - ("Invalid authentication credentials", "invalid_auth"), - ("API rate limit exceeded.", "api_ratelimit"), - ("Something else", "unknown"), + ( + ValueError("Invalid authentication credentials"), + "invalid_auth", + ), + ( + ValueError("API rate limit exceeded."), + "api_ratelimit", + ), + (ValueError("Something else"), "unknown"), + (JSONDecodeError(msg="boom", doc="", pos=1), "unknown"), + (Exception("Boom"), "unknown"), + (Mock(return_value={"error": "boom"}), "unknown"), + (Mock(return_value={"status": "error"}), "unknown"), + ], + ids=[ + "invalid auth", + "rate limit exceeded", + "unknown value error", + "json decode error", + "unknown error", + "error in json dict", + "status error", ], ) -async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> None: +async def test_form_error_handling(hass: HomeAssistant, side_effect, err_code) -> None: """Test we handle expected errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -147,9 +166,9 @@ async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> No with patch( "CO2Signal.get_latest", - side_effect=ValueError(err_str), + side_effect=side_effect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "location": config_flow.TYPE_USE_HOME, @@ -157,71 +176,24 @@ async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> No }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": err_code} - - -async def test_form_invalid_json(hass: HomeAssistant) -> None: - """Test we handle invalid json.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": err_code} with patch( "CO2Signal.get_latest", - side_effect=JSONDecodeError(msg="boom", doc="", pos=1), + return_value=VALID_PAYLOAD, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { "location": config_flow.TYPE_USE_HOME, "api_key": "api_key", }, ) + await hass.async_block_till_done() - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_form_error_unexpected_error(hass: HomeAssistant) -> None: - """Test we handle unexpected error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "CO2Signal.get_latest", - side_effect=Exception("Boom"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "location": config_flow.TYPE_USE_HOME, - "api_key": "api_key", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_form_error_unexpected_data(hass: HomeAssistant) -> None: - """Test we handle unexpected data.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "CO2Signal.get_latest", - return_value={"status": "error"}, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "location": config_flow.TYPE_USE_HOME, - "api_key": "api_key", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "CO2 Signal" + assert result["data"] == { + "api_key": "api_key", + } From 60b8775f4ab6b93323961234caf564487fd1fcc0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 22:36:13 +0200 Subject: [PATCH 785/984] Avoid redundant calls to async_write_ha_state in mqtt siren (#100813) * Avoid redundant calls to async_write_ha_state * Add comment --- homeassistant/components/mqtt/siren.py | 14 +++++-- tests/components/mqtt/test_siren.py | 51 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 0d8a53e98ea..fb0a05c93f5 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -49,7 +49,12 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, + write_state_on_attr_change, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -57,7 +62,6 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data DEFAULT_NAME = "MQTT Siren" DEFAULT_PAYLOAD_ON = "ON" @@ -223,6 +227,7 @@ class MqttSiren(MqttEntity, SirenEntity): @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on", "_extra_attributes"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle new MQTT state messages.""" payload = self._value_template(msg.payload) @@ -265,7 +270,6 @@ class MqttSiren(MqttEntity, SirenEntity): if json_payload[STATE] == PAYLOAD_NONE: self._attr_is_on = None del json_payload[STATE] - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if json_payload: # process attributes @@ -279,8 +283,10 @@ class MqttSiren(MqttEntity, SirenEntity): invalid_siren_parameters, ) return + # To be able to track changes to self._extra_attributes we assign + # a fresh copy to make the original tracked reference immutable. + self._extra_attributes = dict(self._extra_attributes) self._update(process_turn_on_params(self, params)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 4f703ff0023..8a576068216 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -44,6 +44,7 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1092,3 +1093,53 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + DEFAULT_CONFIG, + ( + { + "state_topic": "test-topic", + "available_tones": ["siren", "bell"], + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ("test-topic", "ON", "OFF"), + ("test-topic", '{"state": "ON"}', '{"state": "OFF"}'), + ("test-topic", '{"state":"ON","tone":"siren"}', '{"state":"ON","tone":"bell"}'), + ( + "test-topic", + '{"state":"ON","tone":"siren"}', + '{"state":"OFF","tone":"siren"}', + ), + # Attriute volume_level 2 is invalid, but the state is valid and should update + ( + "test-topic", + '{"state":"ON","volume_level":0.5}', + '{"state":"OFF","volume_level":2}', + ), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) From 90bf20c6f84b4d9b427b610d27c1c1386e793e7f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 23:01:53 +0200 Subject: [PATCH 786/984] Add type hints for intent_script integration (#99393) * Add type hints for intent_script integration * Correct 2nd typo * omit total=False as all options are set --- .../components/intent_script/__init__.py | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 55c4947fe4a..f0cf36b5607 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import TypedDict import voluptuous as vol @@ -62,7 +63,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: +async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None: """Handle start Intent Script service call.""" new_config = await async_integration_yaml_config(hass, DOMAIN) existing_intents = hass.data[DOMAIN] @@ -79,7 +80,7 @@ async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: async_load_intents(hass, new_intents) -def async_load_intents(hass: HomeAssistant, intents: dict): +def async_load_intents(hass: HomeAssistant, intents: dict[str, ConfigType]) -> None: """Load YAML intents into the intent system.""" template.attach(hass, intents) hass.data[DOMAIN] = intents @@ -98,8 +99,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_load_intents(hass, intents) - async def _handle_reload(servie_call: ServiceCall) -> None: - return await async_reload(hass, servie_call) + async def _handle_reload(service_call: ServiceCall) -> None: + return await async_reload(hass, service_call) service.async_register_admin_service( hass, @@ -111,22 +112,41 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +class _IntentSpeechRepromptData(TypedDict): + """Intent config data type for speech or reprompt info.""" + + content: template.Template + title: template.Template + text: template.Template + type: str + + +class _IntentCardData(TypedDict): + """Intent config data type for card info.""" + + type: str + title: template.Template + content: template.Template + + class ScriptIntentHandler(intent.IntentHandler): """Respond to an intent with a script.""" - def __init__(self, intent_type, config): + def __init__(self, intent_type: str, config: ConfigType) -> None: """Initialize the script intent handler.""" self.intent_type = intent_type self.config = config - async def async_handle(self, intent_obj: intent.Intent): + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" - speech = self.config.get(CONF_SPEECH) - reprompt = self.config.get(CONF_REPROMPT) - card = self.config.get(CONF_CARD) - action = self.config.get(CONF_ACTION) - is_async_action = self.config.get(CONF_ASYNC_ACTION) - slots = {key: value["value"] for key, value in intent_obj.slots.items()} + speech: _IntentSpeechRepromptData | None = self.config.get(CONF_SPEECH) + reprompt: _IntentSpeechRepromptData | None = self.config.get(CONF_REPROMPT) + card: _IntentCardData | None = self.config.get(CONF_CARD) + action: script.Script | None = self.config.get(CONF_ACTION) + is_async_action: bool = self.config[CONF_ASYNC_ACTION] + slots: dict[str, str] = { + key: value["value"] for key, value in intent_obj.slots.items() + } _LOGGER.debug( "Intent named %s received with slots: %s", @@ -150,23 +170,23 @@ class ScriptIntentHandler(intent.IntentHandler): if speech is not None: response.async_set_speech( - speech[CONF_TEXT].async_render(slots, parse_result=False), - speech[CONF_TYPE], + speech["text"].async_render(slots, parse_result=False), + speech["type"], ) if reprompt is not None: - text_reprompt = reprompt[CONF_TEXT].async_render(slots, parse_result=False) + text_reprompt = reprompt["text"].async_render(slots, parse_result=False) if text_reprompt: response.async_set_reprompt( text_reprompt, - reprompt[CONF_TYPE], + reprompt["type"], ) if card is not None: response.async_set_card( - card[CONF_TITLE].async_render(slots, parse_result=False), - card[CONF_CONTENT].async_render(slots, parse_result=False), - card[CONF_TYPE], + card["title"].async_render(slots, parse_result=False), + card["content"].async_render(slots, parse_result=False), + card["type"], ) return response From 15f40f2c5a6fc5ba275c48b3e375e5c47729121d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 25 Sep 2023 23:06:48 +0200 Subject: [PATCH 787/984] Bump yt-dlp to 2023.9.24 (#100884) --- 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 707cbdf9e8b..37a8a0d6773 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.7.6"] + "requirements": ["yt-dlp==2023.9.24"] } diff --git a/requirements_all.txt b/requirements_all.txt index a4b2bac8c05..1aa66225281 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2766,7 +2766,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.7.6 +yt-dlp==2023.9.24 # homeassistant.components.zamg zamg==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e50fed5371..c39bc09c96b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2060,7 +2060,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2023.7.6 +yt-dlp==2023.9.24 # homeassistant.components.zamg zamg==0.3.0 From dbdc513aa5ce4da4b07f392f33ca9cdd3ce8b984 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 16:19:03 -0500 Subject: [PATCH 788/984] Bump dbus-fast to 2.10.0 (#100879) --- 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 def08cb914c..ca8e7f3a3c2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.12.0", - "dbus-fast==2.9.0" + "dbus-fast==2.10.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3017327d1df..b6f264f31f5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.12.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.9.0 +dbus-fast==2.10.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.71.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1aa66225281..817a057a1c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -645,7 +645,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.9.0 +dbus-fast==2.10.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c39bc09c96b..9f97364f5b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.9.0 +dbus-fast==2.10.0 # homeassistant.components.debugpy debugpy==1.8.0 From d5c22bec82fb620ba1a44cfbc7ee3b319142f0c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 16:19:24 -0500 Subject: [PATCH 789/984] Bump zeroconf to 0.114.0 (#100880) --- 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 d81ed1dfaaa..0b4db86dad7 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.112.0"] + "requirements": ["zeroconf==0.114.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6f264f31f5..1c0255dd39a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.112.0 +zeroconf==0.114.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 817a057a1c0..42522fc5f31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2775,7 +2775,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.112.0 +zeroconf==0.114.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f97364f5b8..ea09dd31749 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2066,7 +2066,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.112.0 +zeroconf==0.114.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 6387263007fd9b3549237f275469b3ab04007ca3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 25 Sep 2023 16:19:40 -0500 Subject: [PATCH 790/984] Bump async-upnp-client to 0.36.0 (#100881) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 53bda449465..1e604e984b9 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.35.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.36.0", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index d7a72a53411..2f474725ac2 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.35.1"], + "requirements": ["async-upnp-client==0.36.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index be75e3f4465..3d5b766e55b 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.35.1" + "async-upnp-client==0.36.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index c9cf452bac2..3ffe4c8c43a 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.35.1"] + "requirements": ["async-upnp-client==0.36.0"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index e42235af747..7d2ab413504 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.35.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.36.0", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index e510a58b3e7..3b75e4dd5fa 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.35.1"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.36.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1c0255dd39a..893ac1bc26a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiohttp==3.8.5 aiohttp_cors==0.7.0 astral==2.2 async-timeout==4.0.3 -async-upnp-client==0.35.1 +async-upnp-client==0.36.0 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index 42522fc5f31..64f23e8d4a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -458,7 +458,7 @@ async-interrupt==1.1.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.1 +async-upnp-client==0.36.0 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea09dd31749..096669866b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -412,7 +412,7 @@ async-interrupt==1.1.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.1 +async-upnp-client==0.36.0 # homeassistant.components.sleepiq asyncsleepiq==1.3.7 From c5b32d63071452d3cfb03ff308abcca9b9f40c94 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 25 Sep 2023 23:20:02 +0200 Subject: [PATCH 791/984] Add doorbell event to google_assistant (#97123) * First attempt async_report_state_all * Move notificationSupportedByAgent to SYNC response * Make notificationSupportedByAgent conditional * Add generic sync_options method * Report event * Add event_type as ID * User UUID, imlement query_notifications * Refactor query_notifications * Add test * MyPy * Unreachable code * Tweak * Correct notification message * Timestamp was wrong unit, it should be in seconds * Can only allow doorbell class, since it's the only type * Fix test * Remove unrelated changes - improve coverage * Additional tests --------- Co-authored-by: Joakim Plate --- .../components/cloud/google_config.py | 6 +- .../components/google_assistant/const.py | 4 + .../components/google_assistant/helpers.py | 47 +++++- .../components/google_assistant/http.py | 10 +- .../google_assistant/report_state.py | 20 ++- .../components/google_assistant/trait.py | 69 +++++++- .../snapshots/test_diagnostics.ambr | 1 + .../google_assistant/test_helpers.py | 24 ++- .../components/google_assistant/test_http.py | 33 ++++ .../google_assistant/test_report_state.py | 152 +++++++++++++++++- .../components/google_assistant/test_trait.py | 37 +++++ 11 files changed, 390 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 0a49c0b6ed6..c11ec47b2e5 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -345,14 +345,16 @@ class CloudGoogleConfig(AbstractConfig): assistant_options = settings.get(CLOUD_GOOGLE, {}) return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) - async def async_report_state(self, message: Any, agent_user_id: str) -> None: + async def async_report_state( + self, message: Any, agent_user_id: str, event_id: str | None = None + ) -> None: """Send a state report to Google.""" try: await self._cloud.google_report_state.async_send_message(message) except ErrorResponse as err: _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) - async def _async_request_sync_devices(self, agent_user_id: str) -> int: + async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus | int: """Trigger a sync with Google.""" if self._sync_entities_lock.locked(): return HTTPStatus.OK diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 6ec8ca5d747..060f7ce50e5 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -6,6 +6,7 @@ from homeassistant.components import ( camera, climate, cover, + event, fan, group, humidifier, @@ -48,6 +49,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "binary_sensor", "climate", "cover", + "event", "fan", "group", "humidifier", @@ -73,6 +75,7 @@ TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA" TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN" TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER" TYPE_DOOR = f"{PREFIX_TYPES}DOOR" +TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL" TYPE_FAN = f"{PREFIX_TYPES}FAN" TYPE_GARAGE = f"{PREFIX_TYPES}GARAGE" TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" @@ -162,6 +165,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER, (cover.DOMAIN, cover.CoverDeviceClass.WINDOW): TYPE_WINDOW, + (event.DOMAIN, event.EventDeviceClass.DOORBELL): TYPE_DOORBELL, ( humidifier.DOMAIN, humidifier.HumidifierDeviceClass.DEHUMIDIFIER, diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index c1b505b2bd4..ee8e5872348 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -9,6 +9,7 @@ from functools import lru_cache from http import HTTPStatus import logging import pprint +from typing import Any from aiohttp.web import json_response from awesomeversion import AwesomeVersion @@ -183,7 +184,9 @@ class AbstractConfig(ABC): """If an entity should have 2FA checked.""" return True - async def async_report_state(self, message, agent_user_id: str): + async def async_report_state( + self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None + ) -> HTTPStatus | None: """Send a state report to Google.""" raise NotImplementedError @@ -234,6 +237,33 @@ class AbstractConfig(ABC): ) return max(res, default=204) + async def async_sync_notification( + self, agent_user_id: str, event_id: str, payload: dict[str, Any] + ) -> HTTPStatus: + """Sync notification to Google.""" + # Remove any pending sync + self._google_sync_unsub.pop(agent_user_id, lambda: None)() + status = await self.async_report_state(payload, agent_user_id, event_id) + assert status is not None + if status == HTTPStatus.NOT_FOUND: + await self.async_disconnect_agent_user(agent_user_id) + return status + + async def async_sync_notification_all( + self, event_id: str, payload: dict[str, Any] + ) -> HTTPStatus: + """Sync notification to Google for all registered agents.""" + if not self._store.agent_user_ids: + return HTTPStatus.NO_CONTENT + + res = await gather( + *( + self.async_sync_notification(agent_user_id, event_id, payload) + for agent_user_id in self._store.agent_user_ids + ) + ) + return max(res, default=HTTPStatus.NO_CONTENT) + @callback def async_schedule_google_sync(self, agent_user_id: str): """Schedule a sync.""" @@ -617,7 +647,6 @@ class GoogleEntity: state.domain, state.attributes.get(ATTR_DEVICE_CLASS) ), } - # Add aliases if (config_aliases := entity_config.get(CONF_ALIASES, [])) or ( entity_entry and entity_entry.aliases @@ -639,6 +668,10 @@ class GoogleEntity: for trt in traits: device["attributes"].update(trt.sync_attributes()) + # Add trait options + for trt in traits: + device.update(trt.sync_options()) + # Add roomhint if room := entity_config.get(CONF_ROOM_HINT): device["roomHint"] = room @@ -681,6 +714,16 @@ class GoogleEntity: return attrs + @callback + def notifications_serialize(self) -> dict[str, Any] | None: + """Serialize the payload for notifications to be sent.""" + notifications: dict[str, Any] = {} + + for trt in self.traits(): + deep_update(notifications, trt.query_notifications() or {}) + + return notifications or None + @callback def reachable_device_serialize(self): """Serialize entity for a REACHABLE_DEVICE response.""" diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 84d5e4a3364..c0e4f715c16 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -158,7 +158,7 @@ class GoogleConfig(AbstractConfig): """If an entity should have 2FA checked.""" return True - async def _async_request_sync_devices(self, agent_user_id: str): + async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus: if CONF_SERVICE_ACCOUNT in self._config: return await self.async_call_homegraph_api( REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} @@ -220,14 +220,18 @@ class GoogleConfig(AbstractConfig): _LOGGER.error("Could not contact %s", url) return HTTPStatus.INTERNAL_SERVER_ERROR - async def async_report_state(self, message, agent_user_id: str): + async def async_report_state( + self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None + ) -> HTTPStatus: """Send a state report to Google.""" data = { "requestId": uuid4().hex, "agentUserId": agent_user_id, "payload": message, } - await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) + if event_id is not None: + data["eventId"] = event_id + return await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) class GoogleAssistantView(HomeAssistantView): diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 52228bb8715..87af12ad0fc 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import deque import logging from typing import Any +from uuid import uuid4 from homeassistant.const import MATCH_ALL from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback @@ -30,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) @callback def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig): - """Enable state reporting.""" + """Enable state and notification reporting.""" checker = None unsub_pending: CALLBACK_TYPE | None = None pending: deque[dict[str, Any]] = deque([{}]) @@ -79,6 +80,23 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig ): return + if (notifications := entity.notifications_serialize()) is not None: + event_id = uuid4().hex + payload = { + "devices": {"notifications": {entity.state.entity_id: notifications}} + } + _LOGGER.info( + "Sending event notification for entity %s", + entity.state.entity_id, + ) + result = await google_config.async_sync_notification_all(event_id, payload) + if result != 200: + _LOGGER.error( + "Unable to send notification with result code: %s, check log for more" + " info", + result, + ) + try: entity_data = entity.query_serialize() except SmartHomeError as err: diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 425a394b522..a39dfd3f3dc 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from datetime import datetime, timedelta import logging from typing import Any, TypeVar @@ -12,6 +13,7 @@ from homeassistant.components import ( camera, climate, cover, + event, fan, group, humidifier, @@ -74,9 +76,10 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers.network import get_url from homeassistant.util import color as color_util, dt as dt_util +from homeassistant.util.dt import utcnow from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -115,6 +118,7 @@ TRAIT_LOCKUNLOCK = f"{PREFIX_TRAITS}LockUnlock" TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed" TRAIT_MODES = f"{PREFIX_TRAITS}Modes" TRAIT_INPUTSELECTOR = f"{PREFIX_TRAITS}InputSelector" +TRAIT_OBJECTDETECTION = f"{PREFIX_TRAITS}ObjectDetection" TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose" TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume" TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm" @@ -221,7 +225,7 @@ class _Trait(ABC): def supported(domain, features, device_class, attributes): """Test if state is supported.""" - def __init__(self, hass, state, config): + def __init__(self, hass: HomeAssistant, state, config) -> None: """Initialize a trait for a state.""" self.hass = hass self.state = state @@ -231,10 +235,17 @@ class _Trait(ABC): """Return attributes for a sync request.""" raise NotImplementedError + def sync_options(self) -> dict[str, Any]: + """Add options for the sync request.""" + return {} + def query_attributes(self): """Return the attributes of this trait for this entity.""" raise NotImplementedError + def query_notifications(self) -> dict[str, Any] | None: + """Return notifications payload.""" + def can_execute(self, command, params): """Test if command can be executed.""" return command in self.commands @@ -335,6 +346,60 @@ class CameraStreamTrait(_Trait): } +@register_trait +class ObjectDetection(_Trait): + """Trait to object detection. + + https://developers.google.com/actions/smarthome/traits/objectdetection + """ + + name = TRAIT_OBJECTDETECTION + commands = [] + + @staticmethod + def supported(domain, features, device_class, _) -> bool: + """Test if state is supported.""" + return ( + domain == event.DOMAIN and device_class == event.EventDeviceClass.DOORBELL + ) + + def sync_attributes(self): + """Return ObjectDetection attributes for a sync request.""" + return {} + + def sync_options(self) -> dict[str, Any]: + """Add options for the sync request.""" + return {"notificationSupportedByAgent": True} + + def query_attributes(self): + """Return ObjectDetection query attributes.""" + return {} + + def query_notifications(self) -> dict[str, Any] | None: + """Return notifications payload.""" + + if self.state.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}: + return None + + # Only notify if last event was less then 30 seconds ago + time_stamp = datetime.fromisoformat(self.state.state) + if (utcnow() - time_stamp) > timedelta(seconds=30): + return None + + return { + "ObjectDetection": { + "objects": { + "unclassified": 1, + }, + "priority": 0, + "detectionTimestamp": int(time_stamp.timestamp() * 1000), + }, + } + + async def execute(self, command, data, params, challenge): + """Execute an ObjectDetection command.""" + + @register_trait class OnOffTrait(_Trait): """Trait to offer basic on and off functionality. diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 8d425ae0648..dffcddf5de5 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -87,6 +87,7 @@ 'binary_sensor', 'climate', 'cover', + 'event', 'fan', 'group', 'humidifier', diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 001e8ff0d07..57915968933 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -306,7 +306,7 @@ async def test_agent_user_id_connect() -> None: @pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) async def test_report_state_all(agents) -> None: - """Test a disconnect message.""" + """Test sync of all states.""" config = MockConfig(agent_user_ids=agents) data = {} with patch.object(config, "async_report_state") as mock: @@ -314,6 +314,28 @@ async def test_report_state_all(agents) -> None: assert sorted(mock.mock_calls) == sorted(call(data, agent) for agent in agents) +@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) +async def test_sync_entities(agents) -> None: + """Test sync of all entities.""" + config = MockConfig(agent_user_ids=agents) + with patch.object( + config, "async_sync_entities", return_value=HTTPStatus.NO_CONTENT + ) as mock: + await config.async_sync_entities_all() + assert sorted(mock.mock_calls) == sorted(call(agent) for agent in agents) + + +@pytest.mark.parametrize("agents", [{}, {"1"}, {"1", "2"}]) +async def test_sync_notifications(agents) -> None: + """Test sync of notifications.""" + config = MockConfig(agent_user_ids=agents) + with patch.object( + config, "async_sync_notification", return_value=HTTPStatus.NO_CONTENT + ) as mock: + await config.async_sync_notification_all("1234", {}) + assert not agents or bool(mock.mock_calls) and agents + + @pytest.mark.parametrize( ("agents", "result"), [({}, 204), ({"1": 200}, 200), ({"1": 200, "2": 300}, 300)], diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 44dc40f5a47..62d2722c445 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta from http import HTTPStatus from typing import Any from unittest.mock import ANY, patch +from uuid import uuid4 import pytest @@ -195,6 +196,38 @@ async def test_report_state( ) +async def test_report_event( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_storage: dict[str, Any], +) -> None: + """Test the report event function.""" + agent_user_id = "user" + config = GoogleConfig(hass, DUMMY_CONFIG) + await config.async_initialize() + + await config.async_connect_agent_user(agent_user_id) + message = {"devices": {}} + + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + + event_id = uuid4().hex + with patch.object(config, "async_call_homegraph_api") as mock_call: + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await config.async_report_state(message, agent_user_id, event_id=event_id) + mock_call.assert_called_once_with( + REPORT_STATE_BASE_URL, + { + "requestId": ANY, + "agentUserId": agent_user_id, + "payload": message, + "eventId": event_id, + }, + ) + + async def test_google_config_local_fulfillment( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index d6f4043d2f7..4ec61b75171 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -1,5 +1,7 @@ """Test Google report state.""" -from datetime import timedelta +from datetime import datetime, timedelta +from http import HTTPStatus +from time import mktime from unittest.mock import AsyncMock, patch import pytest @@ -9,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import BASIC_CONFIG +from . import BASIC_CONFIG, MockConfig from tests.common import async_fire_time_changed @@ -21,6 +23,9 @@ async def test_report_state( assert await async_setup_component(hass, "switch", {}) hass.states.async_set("light.ceiling", "off") hass.states.async_set("switch.ac", "on") + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) with patch.object( BASIC_CONFIG, "async_report_state_all", AsyncMock() @@ -37,6 +42,7 @@ async def test_report_state( "states": { "light.ceiling": {"on": False, "online": True}, "switch.ac": {"on": True, "online": True}, + "event.doorbell": {"online": True}, } } } @@ -128,3 +134,145 @@ async def test_report_state( await hass.async_block_till_done() assert len(mock_report.mock_calls) == 0 + + +@pytest.mark.freeze_time("2023-08-01 00:00:00") +async def test_report_notifications( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test report state works.""" + config = MockConfig(agent_user_ids={"1"}) + + assert await async_setup_component(hass, "event", {}) + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) + + with patch.object( + config, "async_report_state_all", AsyncMock() + ) as mock_report, patch.object(report_state, "INITIAL_REPORT_DELAY", 0): + report_state.async_enable_report_state(hass, config) + + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T00:01:00+00:00") + ) + await hass.async_block_till_done() + + # Test that enabling report state does a report on event entities + assert len(mock_report.mock_calls) == 1 + assert mock_report.mock_calls[0][1][0] == { + "devices": { + "states": { + "event.doorbell": {"online": True}, + }, + } + } + + with patch.object( + config, "async_report_state", return_value=HTTPStatus(200) + ) as mock_report_state: + event_time = datetime.fromisoformat("2023-08-01T00:02:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T00:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T00:03:00+00:00") + ) + await hass.async_block_till_done() + + assert len(mock_report_state.mock_calls) == 1 + notifications_payload = mock_report_state.mock_calls[0][1][0]["devices"][ + "notifications" + ]["event.doorbell"] + assert notifications_payload == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert "Sending event notification for entity event.doorbell" in caplog.text + assert "Unable to send notification with result code" not in caplog.text + + hass.states.async_set( + "event.doorbell", "unknown", attributes={"device_class": "doorbell"} + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:01:00+00:00") + ) + await hass.async_block_till_done() + + # Test the notification request failed + caplog.clear() + with patch.object( + config, "async_report_state", return_value=HTTPStatus(500) + ) as mock_report_state: + event_time = datetime.fromisoformat("2023-08-01T01:02:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T01:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:03:00+00:00") + ) + await hass.async_block_till_done() + assert len(mock_report_state.mock_calls) == 2 + for call in mock_report_state.mock_calls: + if "notifications" in call[1][0]["devices"]: + notifications = call[1][0]["devices"]["notifications"] + elif "states" in call[1][0]["devices"]: + states = call[1][0]["devices"]["states"] + assert notifications["event.doorbell"] == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert states["event.doorbell"] == {"online": True} + assert "Sending event notification for entity event.doorbell" in caplog.text + assert ( + "Unable to send notification with result code: 500, check log for more info" + in caplog.text + ) + + # Test disconnecting agent user + caplog.clear() + with patch.object( + config, "async_report_state", return_value=HTTPStatus.NOT_FOUND + ) as mock_report_state, patch.object(config, "async_disconnect_agent_user"): + event_time = datetime.fromisoformat("2023-08-01T01:03:57+00:00") + epoc_event_time = int(mktime(event_time.timetuple())) + hass.states.async_set( + "event.doorbell", + "2023-08-01T01:03:57+00:00", + attributes={"device_class": "doorbell"}, + ) + async_fire_time_changed( + hass, datetime.fromisoformat("2023-08-01T01:04:00+00:00") + ) + await hass.async_block_till_done() + assert len(mock_report_state.mock_calls) == 2 + for call in mock_report_state.mock_calls: + if "notifications" in call[1][0]["devices"]: + notifications = call[1][0]["devices"]["notifications"] + elif "states" in call[1][0]["devices"]: + states = call[1][0]["devices"]["states"] + assert notifications["event.doorbell"] == { + "ObjectDetection": { + "objects": {"unclassified": 1}, + "priority": 0, + "detectionTimestamp": epoc_event_time * 1000, + } + } + assert states["event.doorbell"] == {"online": True} + assert "Sending event notification for entity event.doorbell" in caplog.text + assert ( + "Unable to send notification with result code: 404, check log for more info" + in caplog.text + ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index fcbf16c21c7..db4257bb621 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -11,6 +11,7 @@ from homeassistant.components import ( camera, climate, cover, + event, fan, group, humidifier, @@ -220,6 +221,42 @@ async def test_onoff_input_boolean(hass: HomeAssistant) -> None: assert off_calls[0].data == {ATTR_ENTITY_ID: "input_boolean.bla"} +@pytest.mark.freeze_time("2023-08-01T00:02:57+00:00") +async def test_doorbell_event(hass: HomeAssistant) -> None: + """Test doorbell event trait support for input_boolean domain.""" + assert trait.ObjectDetection.supported(event.DOMAIN, 0, "doorbell", None) + + state = State( + "event.bla", + "2023-08-01T00:02:57+00:00", + attributes={"device_class": "doorbell"}, + ) + trt_od = trait.ObjectDetection(hass, state, BASIC_CONFIG) + + assert not trt_od.sync_attributes() + assert trt_od.sync_options() == {"notificationSupportedByAgent": True} + assert not trt_od.query_attributes() + time_stamp = datetime.fromisoformat(state.state) + assert trt_od.query_notifications() == { + "ObjectDetection": { + "objects": { + "unclassified": 1, + }, + "priority": 0, + "detectionTimestamp": int(time_stamp.timestamp() * 1000), + } + } + + # Test that stale notifications (older than 30 s) are dropped + state = State( + "event.bla", + "2023-08-01T00:02:22+00:00", + attributes={"device_class": "doorbell"}, + ) + trt_od = trait.ObjectDetection(hass, state, BASIC_CONFIG) + assert trt_od.query_notifications() is None + + async def test_onoff_switch(hass: HomeAssistant) -> None: """Test OnOff trait support for switch domain.""" assert helpers.get_google_type(switch.DOMAIN, None) is not None From cae19431d124bc8ebce72e2e10e3b6f35768fdac Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 25 Sep 2023 22:26:27 +0100 Subject: [PATCH 792/984] Simplify homekit_controller tests with snapshots (#100885) --- .../snapshots/test_init.ambr | 12712 ++++++++++++++++ .../specific_devices/test_airversa_ap2.py | 122 - .../specific_devices/test_aqara_gateway.py | 127 - .../specific_devices/test_arlo_baby.py | 87 - .../specific_devices/test_ecobee_501.py | 67 - .../specific_devices/test_ecobee_occupancy.py | 42 - .../specific_devices/test_eve_degree.py | 87 - .../specific_devices/test_eve_energy.py | 93 - .../specific_devices/test_haa_fan.py | 82 - .../test_homeassistant_bridge.py | 62 - .../test_homespan_daikin_bridge.py | 51 - .../specific_devices/test_koogeek_ls1.py | 48 +- .../specific_devices/test_koogeek_p1eu.py | 49 - .../specific_devices/test_lennox_e30.py | 54 - .../specific_devices/test_lg_tv.py | 63 - .../test_lutron_caseta_bridge.py | 55 - .../specific_devices/test_mss425f.py | 71 - .../specific_devices/test_mss565.py | 41 - .../specific_devices/test_mysa_living.py | 72 - .../test_nanoleaf_strip_nl55.py | 92 - .../test_netamo_smart_co_alarm.py | 50 - .../test_netatmo_home_coach.py | 45 - .../test_rainmachine_pro_8.py | 84 - .../test_ryse_smart_bridge.py | 230 - .../specific_devices/test_schlage_sense.py | 40 - .../test_simpleconnect_fan.py | 48 - .../specific_devices/test_velux_gateway.py | 100 - .../test_vocolinc_flowerbud.py | 78 - .../homekit_controller/test_init.py | 63 + 29 files changed, 12776 insertions(+), 2039 deletions(-) create mode 100644 tests/components/homekit_controller/snapshots/test_init.ambr delete mode 100644 tests/components/homekit_controller/specific_devices/test_airversa_ap2.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_aqara_gateway.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_arlo_baby.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_ecobee_501.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_eve_degree.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_eve_energy.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_haa_fan.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_lennox_e30.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_lg_tv.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_mss425f.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_mss565.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_mysa_living.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_schlage_sense.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_velux_gateway.py delete mode 100644 tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr new file mode 100644 index 00000000000..4c408f2887e --- /dev/null +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -0,0 +1,12712 @@ +# serializer version: 1 +# name: test_snapshots[airversa_ap2] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0.1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Sleekpoint Innovations', + 'model': 'AP2', + 'name': 'Airversa AP2 1808', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.16', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.airversa_ap2_1808_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Identify', + }), + 'entity_id': 'button.airversa_ap2_1808_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.airversa_ap2_1808_provision_preferred_thread_credentials', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Provision Preferred Thread Credentials', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_112_119', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Provision Preferred Thread Credentials', + }), + 'entity_id': 'button.airversa_ap2_1808_provision_preferred_thread_credentials', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2576_2579', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'Airversa AP2 1808 Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.airversa_ap2_1808_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_filter_lifetime', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Filter lifetime', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32896_32900', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Filter lifetime', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.airversa_ap2_1808_filter_lifetime', + 'state': '100.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airversa_ap2_1808_pm2_5_density', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 PM2.5 Density', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2576_2580', + 'unit_of_measurement': 'µg/m³', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'pm25', + 'friendly_name': 'Airversa AP2 1808 PM2.5 Density', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'entity_id': 'sensor.airversa_ap2_1808_pm2_5_density', + 'state': '3.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airversa_ap2_1808_thread_capabilities', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Thread Capabilities', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_node_capabilities', + 'unique_id': '00:00:00:00:00:00_1_112_115', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Airversa AP2 1808 Thread Capabilities', + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'entity_id': 'sensor.airversa_ap2_1808_thread_capabilities', + 'state': 'router_eligible', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airversa_ap2_1808_thread_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Airversa AP2 1808 Thread Status', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_status', + 'unique_id': '00:00:00:00:00:00_1_112_117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Airversa AP2 1808 Thread Status', + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'entity_id': 'sensor.airversa_ap2_1808_thread_status', + 'state': 'router', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_lock_physical_controls', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Airversa AP2 1808 Lock Physical Controls', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32839', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Lock Physical Controls', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.airversa_ap2_1808_lock_physical_controls', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Airversa AP2 1808 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32843', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.airversa_ap2_1808_mute', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.airversa_ap2_1808_sleep_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:power-sleep', + 'original_name': 'Airversa AP2 1808 Sleep Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_32832_32842', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Airversa AP2 1808 Sleep Mode', + 'icon': 'mdi:power-sleep', + }), + 'entity_id': 'switch.airversa_ap2_1808_sleep_mode', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[anker_eufycam] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '2.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8010', + 'name': 'eufy HomeBase2-0AAA', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.1.6', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufy_homebase2_0aaa_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufy HomeBase2-0AAA Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufy HomeBase2-0AAA Identify', + }), + 'entity_id': 'button.eufy_homebase2_0aaa_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-0000', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_0000_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-0000 Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-0000 Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_0000_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_0000_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-0000 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000 Identify', + }), + 'entity_id': 'button.eufycam2_0000_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_0000', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-0000', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_0000', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_0000_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-0000 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-0000 Battery', + 'icon': 'mdi:battery-20', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_0000_battery', + 'state': '17', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_0000_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-0000 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-0000 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_0000_mute', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-000A', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-000A Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-000A Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_000a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Identify', + }), + 'entity_id': 'button.eufycam2_000a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_000a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_000a', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_000a_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-000A Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-000A Battery', + 'icon': 'mdi:battery-40', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_000a_battery', + 'state': '38', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_000a_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-000A Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_000a_mute', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Anker', + 'model': 'T8113', + 'name': 'eufyCam2-000A', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.6.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'eufyCam2-000A Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'eufyCam2-000A Motion Sensor', + }), + 'entity_id': 'binary_sensor.eufycam2_000a_motion_sensor_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eufycam2_000a_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Identify', + }), + 'entity_id': 'button.eufycam2_000a_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.eufycam2_000a_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'eufyCam2-000A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A', + 'supported_features': , + }), + 'entity_id': 'camera.eufycam2_000a_2', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eufycam2_000a_battery_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'eufyCam2-000A Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_101', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'eufyCam2-000A Battery', + 'icon': 'mdi:battery-alert', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eufycam2_000a_battery_2', + 'state': '100', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eufycam2_000a_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'eufyCam2-000A Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_80_83', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'eufyCam2-000A Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.eufycam2_000a_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_e1] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'HE1-G01', + 'name': 'Aqara-Hub-E1-00A0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.aqara_hub_e1_00a0_security_system', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:security', + 'original_name': 'Aqara-Hub-E1-00A0 Security System', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'changed_by': None, + 'code_arm_required': True, + 'code_format': None, + 'friendly_name': 'Aqara-Hub-E1-00A0 Security System', + 'icon': 'mdi:security', + 'supported_features': , + }), + 'entity_id': 'alarm_control_panel.aqara_hub_e1_00a0_security_system', + 'state': 'disarmed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_hub_e1_00a0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara-Hub-E1-00A0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Identify', + }), + 'entity_id': 'button.aqara_hub_e1_00a0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_hub_e1_00a0_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-high', + 'original_name': 'Aqara-Hub-E1-00A0 Volume', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17_1114116', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Volume', + 'icon': 'mdi:volume-high', + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.aqara_hub_e1_00a0_volume', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aqara_hub_e1_00a0_pairing_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Aqara-Hub-E1-00A0 Pairing Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17_1114117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara-Hub-E1-00A0 Pairing Mode', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.aqara_hub_e1_00a0_pairing_mode', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:33', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'AS006', + 'name': 'Contact Sensor', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.contact_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Contact Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_4', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'opening', + 'friendly_name': 'Contact Sensor', + }), + 'entity_id': 'binary_sensor.contact_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.contact_sensor_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Contact Sensor Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Contact Sensor Identify', + }), + 'entity_id': 'button.contact_sensor_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.contact_sensor_battery_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Contact Sensor Battery Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_33_5', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Contact Sensor Battery Sensor', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.contact_sensor_battery_sensor', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_gateway] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'ZHWA11LM', + 'name': 'Aqara Hub-1563', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.4.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.aqara_hub_1563_security_system', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:security', + 'original_name': 'Aqara Hub-1563 Security System', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_66304', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'changed_by': None, + 'code_arm_required': True, + 'code_format': None, + 'friendly_name': 'Aqara Hub-1563 Security System', + 'icon': 'mdi:security', + 'supported_features': , + }), + 'entity_id': 'alarm_control_panel.aqara_hub_1563_security_system', + 'state': 'disarmed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.aqara_hub_1563_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara Hub-1563 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Identify', + }), + 'entity_id': 'button.aqara_hub_1563_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.aqara_hub_1563_lightbulb_1563', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aqara Hub-1563 Lightbulb-1563', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65792', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Lightbulb-1563', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.aqara_hub_1563_lightbulb_1563', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqara_hub_1563_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-high', + 'original_name': 'Aqara Hub-1563 Volume', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65536_65541', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Volume', + 'icon': 'mdi:volume-high', + 'max': 100, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.aqara_hub_1563_volume', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.aqara_hub_1563_pairing_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Aqara Hub-1563 Pairing Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_65536_65538', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Aqara Hub-1563 Pairing Mode', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.aqara_hub_1563_pairing_mode', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[aqara_switch] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Aqara', + 'model': 'AR004', + 'name': 'Programmable Switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.programmable_switch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Programmable Switch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_65537', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Programmable Switch Identify', + }), + 'entity_id': 'button.programmable_switch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.programmable_switch_battery_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Programmable Switch Battery Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_5', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Programmable Switch Battery Sensor', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.programmable_switch_battery_sensor', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[arlo_baby] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netgear, Inc', + 'model': 'ABC1000', + 'name': 'ArloBabyA0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.10.931', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.arlobabya0_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Motion', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_500', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'ArloBabyA0 Motion', + }), + 'entity_id': 'binary_sensor.arlobabya0_motion', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.arlobabya0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Identify', + }), + 'entity_id': 'button.arlobabya0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.arlobabya0', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0', + 'supported_features': , + }), + 'entity_id': 'camera.arlobabya0', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.arlobabya0_nightlight', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ArloBabyA0 Nightlight', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1100', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Nightlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.arlobabya0_nightlight', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_800_802', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'ArloBabyA0 Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.arlobabya0_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.arlobabya0_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'ArloBabyA0 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_700', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'ArloBabyA0 Battery', + 'icon': 'mdi:battery-80', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.arlobabya0_battery', + 'state': '82', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_900', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'ArloBabyA0 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.arlobabya0_humidity', + 'state': '60.099998', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arlobabya0_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ArloBabyA0 Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1000', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'ArloBabyA0 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.arlobabya0_temperature', + 'state': '24.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.arlobabya0_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'ArloBabyA0 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_300_302', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.arlobabya0_mute', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.arlobabya0_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'ArloBabyA0 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_400_402', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'ArloBabyA0 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.arlobabya0_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[connectsense] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ConnectSense', + 'model': 'CS-IWO', + 'name': 'InWall Outlet-0394DE', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.inwall_outlet_0394de_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Identify', + }), + 'entity_id': 'button.inwall_outlet_0394de_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_current', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Current', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_18', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'InWall Outlet-0394DE Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_current', + 'state': '0.03', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_current_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Current', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_30', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'InWall Outlet-0394DE Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_current_2', + 'state': '0.05', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_20', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'InWall Outlet-0394DE Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh', + 'state': '379.69299', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'InWall Outlet-0394DE Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_energy_kwh_2', + 'state': '175.85001', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'InWall Outlet-0394DE Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_power', + 'state': '0.8', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inwall_outlet_0394de_power_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25_31', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'InWall Outlet-0394DE Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.inwall_outlet_0394de_power_2', + 'state': '0.8', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.inwall_outlet_0394de_outlet_a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Outlet A', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_13', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Outlet A', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.inwall_outlet_0394de_outlet_a', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.inwall_outlet_0394de_outlet_b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'InWall Outlet-0394DE Outlet B', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_25', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'InWall Outlet-0394DE Outlet B', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.inwall_outlet_0394de_outlet_b', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee3] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Basement', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement', + }), + 'entity_id': 'binary_sensor.basement', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_4101', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.basement_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Basement Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.basement_temperature', + 'state': '20.7', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.homew_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Clear Hold', + }), + 'entity_id': 'button.homew_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.homew_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.homew_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Kitchen', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kitchen', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Kitchen', + }), + 'entity_id': 'binary_sensor.kitchen', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2053', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Identify', + }), + 'entity_id': 'button.kitchen_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.kitchen_temperature', + 'state': '21.5', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Porch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.porch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Porch', + }), + 'entity_id': 'binary_sensor.porch', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.porch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Porch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_3077', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Porch Identify', + }), + 'entity_id': 'button.porch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.porch_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Porch Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.porch_temperature', + 'state': '21', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee3_no_sensors] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homew_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'HomeW', + }), + 'entity_id': 'binary_sensor.homew_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.homew_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Clear Hold', + }), + 'entity_id': 'button.homew_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.homew_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.homew_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee_501] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ECB501', + 'name': 'My ecobee', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.7.340214', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_ecobee_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Motion', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'My ecobee Motion', + }), + 'entity_id': 'binary_sensor.my_ecobee_motion', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_ecobee_occupancy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Occupancy', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'My ecobee Occupancy', + }), + 'entity_id': 'binary_sensor.my_ecobee_occupancy', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.my_ecobee_clear_hold', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Clear Hold', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Clear Hold', + }), + 'entity_id': 'button.my_ecobee_clear_hold', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_ecobee_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Identify', + }), + 'entity_id': 'button.my_ecobee_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.my_ecobee', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 55.0, + 'current_temperature': 21.3, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'My ecobee', + 'humidity': 36.0, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': 25.6, + 'target_temp_low': 7.2, + 'temperature': None, + }), + 'entity_id': 'climate.my_ecobee', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.my_ecobee_current_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My ecobee Current Mode', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'ecobee_mode', + 'unique_id': '00:00:00:00:00:00_1_16_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Current Mode', + 'options': list([ + 'home', + 'sleep', + 'away', + ]), + }), + 'entity_id': 'select.my_ecobee_current_mode', + 'state': 'home', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.my_ecobee_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'My ecobee Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'My ecobee Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.my_ecobee_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_ecobee_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'My ecobee Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.my_ecobee_current_humidity', + 'state': '55.0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_ecobee_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My ecobee Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'My ecobee Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.my_ecobee_current_temperature', + 'state': '21.3', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ecobee_occupancy] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee Switch+', + 'name': 'Master Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.5.130201', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'binary_sensor.master_fan', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.master_fan_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_57', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'occupancy', + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'binary_sensor.master_fan_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Fan Identify', + }), + 'entity_id': 'button.master_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_fan_light_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan Light Level', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_27', + 'unit_of_measurement': 'lx', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'illuminance', + 'friendly_name': 'Master Fan Light Level', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'entity_id': 'sensor.master_fan_light_level', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.master_fan_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Master Fan Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Master Fan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.master_fan_temperature', + 'state': '25.6', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.master_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Fan', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Fan', + }), + 'entity_id': 'switch.master_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[eve_degree] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Eve Degree 00AAA0000', + 'name': 'Eve Degree AA11', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.2.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_degree_aa11_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Identify', + }), + 'entity_id': 'button.eve_degree_aa11_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 9000, + 'min': -450, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.eve_degree_aa11_elevation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:elevation-rise', + 'original_name': 'Eve Degree AA11 Elevation', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_33', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Elevation', + 'icon': 'mdi:elevation-rise', + 'max': 9000, + 'min': -450, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.eve_degree_aa11_elevation', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.eve_degree_aa11_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Eve Degree AA11 Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_22_25', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Degree AA11 Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.eve_degree_aa11_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_air_pressure', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Air Pressure', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'pressure', + 'friendly_name': 'Eve Degree AA11 Air Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_degree_aa11_air_pressure', + 'state': '1005.70001220703', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_degree_aa11_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Eve Degree AA11 Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Eve Degree AA11 Battery', + 'icon': 'mdi:battery-60', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eve_degree_aa11_battery', + 'state': '65', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_27', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Eve Degree AA11 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.eve_degree_aa11_humidity', + 'state': '59.4818115234375', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_degree_aa11_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Degree AA11 Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_22', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Eve Degree AA11 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_degree_aa11_temperature', + 'state': '22.7719116210938', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[eve_energy] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Eve Energy 20EAO8601', + 'name': 'Eve Energy 50FF', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.2.9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.eve_energy_50ff_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF Identify', + }), + 'entity_id': 'button.eve_energy_50ff_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_amps', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Amps', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_33', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'current', + 'friendly_name': 'Eve Energy 50FF Amps', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_amps', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_energy_kwh', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Energy kWh', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_35', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'energy', + 'friendly_name': 'Eve Energy 50FF Energy kWh', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_energy_kwh', + 'state': '0.28999999165535', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_34', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Eve Energy 50FF Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eve_energy_50ff_volts', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Eve Energy 50FF Volts', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_32', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Energy 50FF Volts', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.eve_energy_50ff_volts', + 'state': '0.400000005960464', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.eve_energy_50ff', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Eve Energy 50FF', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.eve_energy_50ff', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.eve_energy_50ff_lock_physical_controls', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-open', + 'original_name': 'Eve Energy 50FF Lock Physical Controls', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_28_36', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Eve Energy 50FF Lock Physical Controls', + 'icon': 'mdi:lock-open', + }), + 'entity_id': 'switch.eve_energy_50ff_lock_physical_controls', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[haa_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'José A. Jiménez Campos', + 'model': 'RavenSystem HAA', + 'name': 'HAA-C718B3', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '5.0.18', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Identify', + }), + 'entity_id': 'button.haa_c718b3_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_setup', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'HAA-C718B3 Setup', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1010_1012', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Setup', + 'icon': 'mdi:cog', + }), + 'entity_id': 'button.haa_c718b3_setup', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_update', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HAA-C718B3 Update', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1010_1011', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'update', + 'friendly_name': 'HAA-C718B3 Update', + }), + 'entity_id': 'button.haa_c718b3_update', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.haa_c718b3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3', + 'percentage': 66, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.haa_c718b3', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'José A. Jiménez Campos', + 'model': 'RavenSystem HAA', + 'name': 'HAA-C718B3', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '5.0.18', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Identify', + }), + 'entity_id': 'button.haa_c718b3_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.haa_c718b3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3', + }), + 'entity_id': 'switch.haa_c718b3', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[home_assistant_bridge_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:766313939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Ceiling Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ceiling_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan Identify', + }), + 'entity_id': 'button.ceiling_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.ceiling_fan', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'oscillating': False, + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[homespan_daikin_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Garzola Marco', + 'model': 'Daikin-fwec3a-esp32-homekit-bridge', + 'name': 'Air Conditioner', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.air_conditioner_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Conditioner Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Air Conditioner Identify', + }), + 'entity_id': 'button.air_conditioner_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'target_temp_step': 0.5, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.air_conditioner_slaveid_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air Conditioner SlaveID 1', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_temperature': 27.9, + 'fan_mode': 'high', + 'fan_modes': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Air Conditioner SlaveID 1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 24.5, + }), + 'entity_id': 'climate.air_conditioner_slaveid_1', + 'state': 'cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_conditioner_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air Conditioner Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9_11', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Conditioner Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.air_conditioner_current_temperature', + 'state': '27.9', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[hue_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462395276914', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276914_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276914_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_4', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462395276939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276939_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462395276939_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_3', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462403113447', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403113447_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403113447_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle_2', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462403233419', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW012', + 'name': 'Hue ambiance candle', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_candle_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403233419_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle Identify', + }), + 'entity_id': 'button.hue_ambiance_candle_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_candle', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance candle', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462403233419_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance candle', + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_ambiance_candle', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462412411853', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW013', + 'name': 'Hue ambiance spot', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_spot_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412411853_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance spot Identify', + }), + 'entity_id': 'button.hue_ambiance_spot_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_spot_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412411853_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'color_temp': 366, + 'color_temp_kelvin': 2732, + 'friendly_name': 'Hue ambiance spot', + 'hs_color': tuple( + 28.327, + 64.71, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 89, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.524, + 0.387, + ), + }), + 'entity_id': 'light.hue_ambiance_spot_2', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462412413293', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LTW013', + 'name': 'Hue ambiance spot', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_ambiance_spot_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412413293_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue ambiance spot Identify', + }), + 'entity_id': 'button.hue_ambiance_spot_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_ambiance_spot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue ambiance spot', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462412413293_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'color_temp': 366, + 'color_temp_kelvin': 2732, + 'friendly_name': 'Hue ambiance spot', + 'hs_color': tuple( + 28.327, + 64.71, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 167, + 89, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.524, + 0.387, + ), + }), + 'entity_id': 'light.hue_ambiance_spot', + 'state': 'on', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462389072572', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'RWL021', + 'name': 'Hue dimmer switch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '45.1.17846', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_dimmer_switch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue dimmer switch Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462389072572_1_22', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue dimmer switch Identify', + }), + 'entity_id': 'button.hue_dimmer_switch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410585088', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 1', + }), + 'entity_id': 'event.hue_dimmer_switch_button_1', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410650624', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 2', + }), + 'entity_id': 'event.hue_dimmer_switch_button_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410716160', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 3', + }), + 'entity_id': 'event.hue_dimmer_switch_button_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.hue_dimmer_switch_button_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hue dimmer switch button 4', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00:00:00:00:00:00_6623462389072572_588410781696', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'single_press', + ]), + 'friendly_name': 'Hue dimmer switch button 4', + }), + 'entity_id': 'event.hue_dimmer_switch_button_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.hue_dimmer_switch_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Hue dimmer switch battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462389072572_644245094400', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Hue dimmer switch battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.hue_dimmer_switch_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462378982941', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378982941_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378982941_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462378983942', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378983942_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_2', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462378983942_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_2', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462379122122', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379122122_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_4', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379122122_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_4', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462379123707', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379123707_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_3', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462379123707_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_3', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462383114163', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114163_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_7', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114163_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_7', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462383114193', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114193_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_6', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462383114193_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_6', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:6623462385996792', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips', + 'model': 'LWB010', + 'name': 'Hue white lamp', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.46.13', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.hue_white_lamp_identify_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462385996792_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp Identify', + }), + 'entity_id': 'button.hue_white_lamp_identify_5', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.hue_white_lamp_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hue white lamp', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_6623462385996792_2816', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Hue white lamp', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.hue_white_lamp_5', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Philips Lighting', + 'model': 'BSB002', + 'name': 'Philips hue - 482544', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.32.1932126170', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.philips_hue_482544_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Philips hue - 482544 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Philips hue - 482544 Identify', + }), + 'entity_id': 'button.philips_hue_482544_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_ls1] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.2.15', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_p1eu] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'P1EU', + 'name': 'Koogeek-P1-A00AA0', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.3.7', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_p1_a00aa0_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-P1-A00AA0 Identify', + }), + 'entity_id': 'button.koogeek_p1_a00aa0_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koogeek_p1_a00aa0_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_21_22', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Koogeek-P1-A00AA0 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.koogeek_p1_a00aa0_power', + 'state': '5', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_p1_a00aa0_outlet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-P1-A00AA0 outlet', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-P1-A00AA0 outlet', + 'outlet_in_use': True, + }), + 'entity_id': 'switch.koogeek_p1_a00aa0_outlet', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[koogeek_sw2] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Koogeek', + 'model': 'KH02CN', + 'name': 'Koogeek-SW2-187A91', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.koogeek_sw2_187a91_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Identify', + }), + 'entity_id': 'button.koogeek_sw2_187a91_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.koogeek_sw2_187a91_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_14_18', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'Koogeek-SW2-187A91 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.koogeek_sw2_187a91_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_sw2_187a91_switch_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Switch 1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Switch 1', + }), + 'entity_id': 'switch.koogeek_sw2_187a91_switch_1', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.koogeek_sw2_187a91_switch_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-SW2-187A91 Switch 2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_11', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-SW2-187A91 Switch 2', + }), + 'entity_id': 'switch.koogeek_sw2_187a91_switch_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lennox_e30] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '3.0.XX', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lennox', + 'model': 'E30 2B', + 'name': 'Lennox', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.40.XX', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lennox_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lennox Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Lennox Identify', + }), + 'entity_id': 'button.lennox_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 37, + 'min_temp': 4.5, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.lennox', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lennox', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 20.5, + 'friendly_name': 'Lennox', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 37, + 'min_temp': 4.5, + 'supported_features': , + 'target_temp_high': 29.5, + 'target_temp_low': 21, + 'temperature': None, + }), + 'entity_id': 'climate.lennox', + 'state': 'heat_cool', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lennox_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Lennox Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_100_105', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Lennox Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.lennox_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lennox_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lennox Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100_107', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Lennox Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lennox_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.lennox_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lennox Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_100_103', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Lennox Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.lennox_current_temperature', + 'state': '20.5', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lg_tv] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'LG Electronics', + 'model': 'OLED55B9PUA', + 'name': 'LG webOS TV AF80', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '04.71.04', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lg_webos_tv_af80_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LG webOS TV AF80 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LG webOS TV AF80 Identify', + }), + 'entity_id': 'button.lg_webos_tv_af80_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'AirPlay', + 'Live TV', + 'HDMI 1', + 'Sony', + 'Apple', + 'AV', + 'HDMI 4', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.lg_webos_tv_af80', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LG webOS TV AF80', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'tv', + 'friendly_name': 'LG webOS TV AF80', + 'source': 'HDMI 4', + 'source_list': list([ + 'AirPlay', + 'Live TV', + 'HDMI 1', + 'Sony', + 'Apple', + 'AV', + 'HDMI 4', + ]), + 'supported_features': , + }), + 'entity_id': 'media_player.lg_webos_tv_af80', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.lg_webos_tv_af80_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'LG webOS TV AF80 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_80_82', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LG webOS TV AF80 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.lg_webos_tv_af80_mute', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[lutron_caseta_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:21474836482', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lutron Electronics Co., Inc', + 'model': 'PD-FSQN-XX', + 'name': 'Caséta® Wireless Fan Speed Control', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '001.005', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.caseta_r_wireless_fan_speed_control_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Caséta® Wireless Fan Speed Control Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_21474836482_1_85899345921', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Caséta® Wireless Fan Speed Control Identify', + }), + 'entity_id': 'button.caseta_r_wireless_fan_speed_control_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Caséta® Wireless Fan Speed Control', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_21474836482_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Caséta® Wireless Fan Speed Control', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.caseta_r_wireless_fan_speed_control', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Lutron Electronics Co., Inc', + 'model': 'L-BDG2-WH', + 'name': 'Smart Bridge 2', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '08.08', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.smart_bridge_2_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart Bridge 2 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_85899345921', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Smart Bridge 2 Identify', + }), + 'entity_id': 'button.smart_bridge_2_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mss425f] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '4.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Meross', + 'model': 'MSS425F', + 'name': 'MSS425F-15cc', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mss425f_15cc_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Identify', + }), + 'entity_id': 'button.mss425f_15cc_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-1', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_12', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-1', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_1', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-2', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_15', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-2', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_2', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-3', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_18', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-3', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_3', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_outlet_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc Outlet-4', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc Outlet-4', + }), + 'entity_id': 'switch.mss425f_15cc_outlet_4', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mss425f_15cc_usb', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS425F-15cc USB', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_24', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS425F-15cc USB', + }), + 'entity_id': 'switch.mss425f_15cc_usb', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mss565] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '4.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Meross', + 'model': 'MSS565', + 'name': 'MSS565-28da', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.1.9', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mss565_28da_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS565-28da Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'MSS565-28da Identify', + }), + 'entity_id': 'button.mss565_28da_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mss565_28da_dimmer_switch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'MSS565-28da Dimmer Switch', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_12', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 170.85, + 'color_mode': , + 'friendly_name': 'MSS565-28da Dimmer Switch', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.mss565_28da_dimmer_switch', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[mysa_living] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Empowered Homes Inc.', + 'model': 'v1', + 'name': 'Mysa-85dda9', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '2.8.1', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mysa_85dda9_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Identify', + }), + 'entity_id': 'button.mysa_85dda9_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mysa_85dda9_thermostat', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Thermostat', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 40, + 'current_temperature': 24.1, + 'friendly_name': 'Mysa-85dda9 Thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': None, + }), + 'entity_id': 'climate.mysa_85dda9_thermostat', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mysa_85dda9_display', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Display', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_40', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Display', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.mysa_85dda9_display', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.mysa_85dda9_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'Mysa-85dda9 Temperature Display Units', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_20_26', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Mysa-85dda9 Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.mysa_85dda9_temperature_display_units', + 'state': 'celsius', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mysa_85dda9_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_27', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Mysa-85dda9 Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.mysa_85dda9_current_humidity', + 'state': '40', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mysa_85dda9_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mysa-85dda9 Current Temperature', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_25', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Mysa-85dda9 Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.mysa_85dda9_current_temperature', + 'state': '24.1', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[nanoleaf_strip_nl55] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.2.4', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Nanoleaf', + 'model': 'NL55', + 'name': 'Nanoleaf Strip 3B32', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.4.40', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.nanoleaf_strip_3b32_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Nanoleaf Strip 3B32 Identify', + }), + 'entity_id': 'button.nanoleaf_strip_3b32_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.nanoleaf_strip_3b32_provision_preferred_thread_credentials', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Provision Preferred Thread Credentials', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_31_119', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Nanoleaf Strip 3B32 Provision Preferred Thread Credentials', + }), + 'entity_id': 'button.nanoleaf_strip_3b32_provision_preferred_thread_credentials', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 470, + 'min_color_temp_kelvin': 2127, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.nanoleaf_strip_3b32_nanoleaf_light_strip', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_19', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 255.0, + 'color_mode': , + 'friendly_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', + 'hs_color': tuple( + 30.0, + 89.0, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 470, + 'min_color_temp_kelvin': 2127, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 141, + 28, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.589, + 0.385, + ), + }), + 'entity_id': 'light.nanoleaf_strip_3b32_nanoleaf_light_strip', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_capabilities', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Thread Capabilities', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_node_capabilities', + 'unique_id': '00:00:00:00:00:00_1_31_115', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Nanoleaf Strip 3B32 Thread Capabilities', + 'options': list([ + 'border_router_capable', + 'full', + 'minimal', + 'none', + 'router_eligible', + 'sleepy', + ]), + }), + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_capabilities', + 'state': 'border_router_capable', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nanoleaf Strip 3B32 Thread Status', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'thread_status', + 'unique_id': '00:00:00:00:00:00_1_31_117', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'enum', + 'friendly_name': 'Nanoleaf Strip 3B32 Thread Status', + 'options': list([ + 'border_router', + 'child', + 'detached', + 'disabled', + 'joining', + 'leader', + 'router', + ]), + }), + 'entity_id': 'sensor.nanoleaf_strip_3b32_thread_status', + 'state': 'border_router', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netamo_doorbell] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Netatmo Doorbell', + 'name': 'Netatmo-Doorbell-g738658', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '80.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.netatmo_doorbell_g738658_motion_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658 Motion Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_10', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Netatmo-Doorbell-g738658 Motion Sensor', + }), + 'entity_id': 'binary_sensor.netatmo_doorbell_g738658_motion_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.netatmo_doorbell_g738658_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Identify', + }), + 'entity_id': 'button.netatmo_doorbell_g738658_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.netatmo_doorbell_g738658', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658', + 'supported_features': , + }), + 'entity_id': 'camera.netatmo_doorbell_g738658', + 'state': 'idle', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'single_press', + 'double_press', + 'long_press', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.netatmo_doorbell_g738658', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Netatmo-Doorbell-g738658', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': 'doorbell', + 'unique_id': '00:00:00:00:00:00_1_49', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'doorbell', + 'event_type': None, + 'event_types': list([ + 'single_press', + 'double_press', + 'long_press', + ]), + 'friendly_name': 'Netatmo-Doorbell-g738658', + }), + 'entity_id': 'event.netatmo_doorbell_g738658', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.netatmo_doorbell_g738658_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Netatmo-Doorbell-g738658 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_51_52', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.netatmo_doorbell_g738658_mute', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.netatmo_doorbell_g738658_mute_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:volume-mute', + 'original_name': 'Netatmo-Doorbell-g738658 Mute', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Netatmo-Doorbell-g738658 Mute', + 'icon': 'mdi:volume-mute', + }), + 'entity_id': 'switch.netatmo_doorbell_g738658_mute_2', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netamo_smart_co_alarm] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Smart CO Alarm', + 'name': 'Smart CO Alarm', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smart_co_alarm_carbon_monoxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart CO Alarm Carbon Monoxide Sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_22', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Smart CO Alarm Carbon Monoxide Sensor', + }), + 'entity_id': 'binary_sensor.smart_co_alarm_carbon_monoxide_sensor', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smart_co_alarm_low_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart CO Alarm Low Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_36', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Smart CO Alarm Low Battery', + }), + 'entity_id': 'binary_sensor.smart_co_alarm_low_battery', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.smart_co_alarm_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart CO Alarm Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_7_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Smart CO Alarm Identify', + }), + 'entity_id': 'button.smart_co_alarm_identify', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[netatmo_home_coach] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Netatmo', + 'model': 'Healthy Home Coach', + 'name': 'Healthy Home Coach', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '59', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.healthy_home_coach_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Healthy Home Coach Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Healthy Home Coach Identify', + }), + 'entity_id': 'button.healthy_home_coach_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Air Quality', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_24_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'aqi', + 'friendly_name': 'Healthy Home Coach Air Quality', + 'state_class': , + }), + 'entity_id': 'sensor.healthy_home_coach_air_quality', + 'state': '1', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_carbon_dioxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Carbon Dioxide sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_10', + 'unit_of_measurement': 'ppm', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Healthy Home Coach Carbon Dioxide sensor', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.healthy_home_coach_carbon_dioxide_sensor', + 'state': '804', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_humidity_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Humidity sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_14', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'Healthy Home Coach Humidity sensor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.healthy_home_coach_humidity_sensor', + 'state': '59', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_noise', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Noise', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_20_21', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'sound_pressure', + 'friendly_name': 'Healthy Home Coach Noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.healthy_home_coach_noise', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.healthy_home_coach_temperature_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Healthy Home Coach Temperature sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_17', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Healthy Home Coach Temperature sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.healthy_home_coach_temperature_sensor', + 'state': '22.9', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[rainmachine-pro-8] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Green Electronics LLC', + 'model': 'SPK5 Pro', + 'name': 'RainMachine-00ce4a', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.rainmachine_00ce4a_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RainMachine-00ce4a Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a Identify', + }), + 'entity_id': 'button.rainmachine_00ce4a_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_512', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_768', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_2', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1024', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_3', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_4', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1280', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_4', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1536', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_5', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1792', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_6', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_7', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2048', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_7', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.rainmachine_00ce4a_8', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'RainMachine-00ce4a', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_2304', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RainMachine-00ce4a', + 'icon': 'mdi:water', + 'in_use': False, + 'is_configured': True, + 'remaining_duration': 0, + }), + 'entity_id': 'switch.rainmachine_00ce4a_8', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ryse_smart_bridge] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'Master Bath South', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.master_bath_south_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Bath South Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Master Bath South Identify', + }), + 'entity_id': 'button.master_bath_south_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.master_bath_south_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Master Bath South RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'Master Bath South RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.master_bath_south_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.master_bath_south_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Master Bath South RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'Master Bath South RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.master_bath_south_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0101.3521.0436', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE SmartBridge', + 'name': 'RYSE SmartBridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartbridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartBridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartBridge Identify', + }), + 'entity_id': 'button.ryse_smartbridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'RYSE SmartShade', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartshade_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartShade Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartShade Identify', + }), + 'entity_id': 'button.ryse_smartshade_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.ryse_smartshade_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartShade RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'RYSE SmartShade RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.ryse_smartshade_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ryse_smartshade_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'RYSE SmartShade RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'RYSE SmartShade RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.ryse_smartshade_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[ryse_smart_bridge_four_shades] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'BR Left', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.br_left_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'BR Left Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'BR Left Identify', + }), + 'entity_id': 'button.br_left_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.br_left_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'BR Left RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'BR Left RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.br_left_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.br_left_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'BR Left RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'BR Left RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.br_left_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'LR Left', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lr_left_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Left Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LR Left Identify', + }), + 'entity_id': 'button.lr_left_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.lr_left_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Left RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'LR Left RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.lr_left_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lr_left_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'LR Left RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'LR Left RYSE Shade Battery', + 'icon': 'mdi:battery-90', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lr_left_ryse_shade_battery', + 'state': '89', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'LR Right', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.lr_right_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Right Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'LR Right Identify', + }), + 'entity_id': 'button.lr_right_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.lr_right_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LR Right RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'friendly_name': 'LR Right RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.lr_right_ryse_shade', + 'state': 'closed', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.lr_right_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'LR Right RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'LR Right RYSE Shade Battery', + 'icon': 'mdi:battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.lr_right_ryse_shade_battery', + 'state': '100', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0401.3521.0679', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE SmartBridge', + 'name': 'RYSE SmartBridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.3.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ryse_smartbridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RYSE SmartBridge Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RYSE SmartBridge Identify', + }), + 'entity_id': 'button.ryse_smartbridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:5', + ]), + ]), + 'is_new': False, + 'manufacturer': 'RYSE Inc.', + 'model': 'RYSE Shade', + 'name': 'RZSS', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.0.8', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.rzss_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RZSS Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'RZSS Identify', + }), + 'entity_id': 'button.rzss_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.rzss_ryse_shade', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RZSS RYSE Shade', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 100, + 'friendly_name': 'RZSS RYSE Shade', + 'supported_features': , + }), + 'entity_id': 'cover.rzss_ryse_shade', + 'state': 'open', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rzss_ryse_shade_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'RZSS RYSE Shade Battery', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_5_64', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'battery', + 'friendly_name': 'RZSS RYSE Shade Battery', + 'icon': 'mdi:battery-alert', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.rzss_ryse_shade_battery', + 'state': '0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[schlage_sense] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.3.0', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Schlage ', + 'model': 'BE479CAM619', + 'name': 'SENSE ', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '004.027.000', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.sense_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SENSE Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_3', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SENSE Identify', + }), + 'entity_id': 'button.sense_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.sense_lock_mechanism', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SENSE Lock Mechanism', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SENSE Lock Mechanism', + 'supported_features': , + }), + 'entity_id': 'lock.sense_lock_mechanism', + 'state': 'unknown', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[simpleconnect_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Hunter Fan', + 'model': 'SIMPLEconnect', + 'name': 'SIMPLEconnect Fan-06F674', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.simpleconnect_fan_06f674_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'SIMPLEconnect Fan-06F674 Identify', + }), + 'entity_id': 'button.simpleconnect_fan_06f674_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.simpleconnect_fan_06f674_hunter_fan', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.simpleconnect_fan_06f674_hunter_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Light', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_29', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 76.5, + 'color_mode': , + 'friendly_name': 'SIMPLEconnect Fan-06F674 Hunter Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'entity_id': 'light.simpleconnect_fan_06f674_hunter_light', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[velux_gateway] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Gateway', + 'name': 'VELUX Gateway', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '70', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_gateway_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Gateway Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Gateway Identify', + }), + 'entity_id': 'button.velux_gateway_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Sensor', + 'name': 'VELUX Sensor', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '16', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_sensor_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Sensor Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Sensor Identify', + }), + 'entity_id': 'button.velux_sensor_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_carbon_dioxide_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Carbon Dioxide sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_14', + 'unit_of_measurement': 'ppm', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'VELUX Sensor Carbon Dioxide sensor', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.velux_sensor_carbon_dioxide_sensor', + 'state': '400', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_humidity_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Humidity sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_11', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'VELUX Sensor Humidity sensor', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.velux_sensor_humidity_sensor', + 'state': '58', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.velux_sensor_temperature_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Sensor Temperature sensor', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_8', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'VELUX Sensor Temperature sensor', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.velux_sensor_temperature_sensor', + 'state': '18.9', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VELUX', + 'model': 'VELUX Window', + 'name': 'VELUX Window', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '48', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.velux_window_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VELUX Window Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VELUX Window Identify', + }), + 'entity_id': 'button.velux_window_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.velux_window_roof_window', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VELUX Window Roof Window', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_position': 0, + 'device_class': 'window', + 'friendly_name': 'VELUX Window Roof Window', + 'supported_features': , + }), + 'entity_id': 'cover.velux_window_roof_window', + 'state': 'closed', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[vocolinc_flowerbud] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0.1', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VOCOlinc', + 'model': 'Flowerbud', + 'name': 'VOCOlinc-Flowerbud-0d324b', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '3.121.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vocolinc_flowerbud_0d324b_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Identify', + }), + 'entity_id': 'button.vocolinc_flowerbud_0d324b_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.vocolinc_flowerbud_0d324b', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b', + 'platform': 'homekit_controller', + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'available_modes': list([ + 'normal', + 'auto', + ]), + 'current_humidity': 45.0, + 'device_class': 'humidifier', + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b', + 'humidity': 100.0, + 'max_humidity': 100, + 'min_humidity': 0, + 'mode': 'normal', + 'supported_features': , + }), + 'entity_id': 'humidifier.vocolinc_flowerbud_0d324b', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.vocolinc_flowerbud_0d324b_mood_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_9', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'brightness': 127.5, + 'color_mode': , + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', + 'hs_color': tuple( + 120.0, + 100.0, + ), + 'rgb_color': tuple( + 0, + 255, + 0, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.172, + 0.747, + ), + }), + 'entity_id': 'light.vocolinc_flowerbud_0d324b_mood_light', + 'state': 'on', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.vocolinc_flowerbud_0d324b_spray_quantity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_38', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', + 'icon': 'mdi:water', + 'max': 5, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'entity_id': 'number.vocolinc_flowerbud_0d324b_spray_quantity', + 'state': '5', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vocolinc_flowerbud_0d324b_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_30_33', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.vocolinc_flowerbud_0d324b_current_humidity', + 'state': '45.0', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[vocolinc_vp3] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.3', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'VOCOlinc', + 'model': 'VP3', + 'name': 'VOCOlinc-VP3-123456', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.101.2', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.vocolinc_vp3_123456_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Identify', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-VP3-123456 Identify', + }), + 'entity_id': 'button.vocolinc_vp3_123456_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vocolinc_vp3_123456_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Power', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48_97', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'power', + 'friendly_name': 'VOCOlinc-VP3-123456 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.vocolinc_vp3_123456_power', + 'state': '0', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.vocolinc_vp3_123456_outlet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOCOlinc-VP3-123456 Outlet', + 'platform': 'homekit_controller', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_48', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'VOCOlinc-VP3-123456 Outlet', + }), + 'entity_id': 'switch.vocolinc_vp3_123456_outlet', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- diff --git a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py b/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py deleted file mode 100644 index 0091fc098de..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Tests for Airversa AP2 Air Purifier.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_airversa_ap2_setup(hass: HomeAssistant) -> None: - """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "airversa_ap2.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Airversa AP2 1808", - model="AP2", - manufacturer="Sleekpoint Innovations", - sw_version="0.8.16", - hw_version="0.1", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_lock_physical_controls", - friendly_name="Airversa AP2 1808 Lock Physical Controls", - unique_id="00:00:00:00:00:00_1_32832_32839", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_mute", - friendly_name="Airversa AP2 1808 Mute", - unique_id="00:00:00:00:00:00_1_32832_32843", - entity_category=EntityCategory.CONFIG, - state="on", - ), - EntityTestInfo( - entity_id="switch.airversa_ap2_1808_sleep_mode", - friendly_name="Airversa AP2 1808 Sleep Mode", - unique_id="00:00:00:00:00:00_1_32832_32842", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_air_quality", - friendly_name="Airversa AP2 1808 Air Quality", - unique_id="00:00:00:00:00:00_1_2576_2579", - state="1", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_filter_lifetime", - friendly_name="Airversa AP2 1808 Filter lifetime", - unique_id="00:00:00:00:00:00_1_32896_32900", - state="100.0", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_pm2_5_density", - friendly_name="Airversa AP2 1808 PM2.5 Density", - unique_id="00:00:00:00:00:00_1_2576_2580", - state="3.0", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_thread_capabilities", - friendly_name="Airversa AP2 1808 Thread Capabilities", - unique_id="00:00:00:00:00:00_1_112_115", - state="router_eligible", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router_capable", - "full", - "minimal", - "none", - "router_eligible", - "sleepy", - ] - }, - ), - EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_thread_status", - friendly_name="Airversa AP2 1808 Thread Status", - unique_id="00:00:00:00:00:00_1_112_117", - state="router", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router", - "child", - "detached", - "disabled", - "joining", - "leader", - "router", - ] - }, - ), - EntityTestInfo( - entity_id="button.airversa_ap2_1808_identify", - friendly_name="Airversa AP2 1808 Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py deleted file mode 100644 index 30ecc298d40..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Regression tests for Aqara Gateway V3. - -https://github.com/home-assistant/core/issues/20957 -""" -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature -from homeassistant.components.number import NumberMode -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_aqara_gateway_setup(hass: HomeAssistant) -> None: - """Test that a Aqara Gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "aqara_gateway.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Aqara Hub-1563", - model="ZHWA11LM", - manufacturer="Aqara", - sw_version="1.4.7", - hw_version="", - serial_number="0000000123456789", - devices=[], - entities=[ - EntityTestInfo( - "alarm_control_panel.aqara_hub_1563_security_system", - friendly_name="Aqara Hub-1563 Security System", - unique_id="00:00:00:00:00:00_1_66304", - supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY, - state="disarmed", - ), - EntityTestInfo( - "light.aqara_hub_1563_lightbulb_1563", - friendly_name="Aqara Hub-1563 Lightbulb-1563", - unique_id="00:00:00:00:00:00_1_65792", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - EntityTestInfo( - "number.aqara_hub_1563_volume", - friendly_name="Aqara Hub-1563 Volume", - unique_id="00:00:00:00:00:00_1_65536_65541", - capabilities={ - "max": 100, - "min": 0, - "mode": NumberMode.AUTO, - "step": 1, - }, - entity_category=EntityCategory.CONFIG, - state="40", - ), - EntityTestInfo( - "switch.aqara_hub_1563_pairing_mode", - friendly_name="Aqara Hub-1563 Pairing Mode", - unique_id="00:00:00:00:00:00_1_65536_65538", - entity_category=EntityCategory.CONFIG, - state="off", - ), - ], - ), - ) - - -async def test_aqara_gateway_e1_setup(hass: HomeAssistant) -> None: - """Test that an Aqara E1 Gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "aqara_e1.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Aqara-Hub-E1-00A0", - model="HE1-G01", - manufacturer="Aqara", - sw_version="3.3.0", - hw_version="1.0", - serial_number="00aa00000a0", - devices=[], - entities=[ - EntityTestInfo( - "alarm_control_panel.aqara_hub_e1_00a0_security_system", - friendly_name="Aqara-Hub-E1-00A0 Security System", - unique_id="00:00:00:00:00:00_1_16", - supported_features=AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY, - state="disarmed", - ), - EntityTestInfo( - "number.aqara_hub_e1_00a0_volume", - friendly_name="Aqara-Hub-E1-00A0 Volume", - unique_id="00:00:00:00:00:00_1_17_1114116", - capabilities={ - "max": 100, - "min": 0, - "mode": NumberMode.AUTO, - "step": 1, - }, - entity_category=EntityCategory.CONFIG, - state="40", - ), - EntityTestInfo( - "switch.aqara_hub_e1_00a0_pairing_mode", - friendly_name="Aqara-Hub-E1-00A0 Pairing Mode", - unique_id="00:00:00:00:00:00_1_17_1114117", - entity_category=EntityCategory.CONFIG, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py deleted file mode 100644 index ae44f7f774f..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Make sure that an Arlo Baby can be setup.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_arlo_baby_setup(hass: HomeAssistant) -> None: - """Test that an Arlo Baby can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "arlo_baby.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="ArloBabyA0", - model="ABC1000", - manufacturer="Netgear, Inc", - sw_version="1.10.931", - hw_version="", - serial_number="00A0000000000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="camera.arlobabya0", - unique_id="00:00:00:00:00:00_1", - friendly_name="ArloBabyA0", - state="idle", - ), - EntityTestInfo( - entity_id="binary_sensor.arlobabya0_motion", - unique_id="00:00:00:00:00:00_1_500", - friendly_name="ArloBabyA0 Motion", - state="off", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_battery", - unique_id="00:00:00:00:00:00_1_700", - friendly_name="ArloBabyA0 Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="82", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_humidity", - unique_id="00:00:00:00:00:00_1_900", - friendly_name="ArloBabyA0 Humidity", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="60.099998", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_temperature", - unique_id="00:00:00:00:00:00_1_1000", - friendly_name="ArloBabyA0 Temperature", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="24.0", - ), - EntityTestInfo( - entity_id="sensor.arlobabya0_air_quality", - unique_id="00:00:00:00:00:00_1_800_802", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - friendly_name="ArloBabyA0 Air Quality", - state="1", - ), - EntityTestInfo( - entity_id="light.arlobabya0_nightlight", - unique_id="00:00:00:00:00:00_1_1100", - friendly_name="ArloBabyA0 Nightlight", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py deleted file mode 100644 index c833ea71116..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for Ecobee 501.""" -from homeassistant.components.climate import ( - SUPPORT_FAN_MODE, - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.const import STATE_ON -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_ecobee501_setup(hass: HomeAssistant) -> None: - """Test that a Ecobee 501 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ecobee_501.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="My ecobee", - model="ECB501", - manufacturer="ecobee Inc.", - sw_version="4.7.340214", - hw_version="", - serial_number="123456789016", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.my_ecobee", - friendly_name="My ecobee", - unique_id="00:00:00:00:00:00_1_16", - supported_features=( - SUPPORT_TARGET_TEMPERATURE - | SUPPORT_TARGET_TEMPERATURE_RANGE - | SUPPORT_TARGET_HUMIDITY - | SUPPORT_FAN_MODE - ), - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "fan_modes": ["on", "auto"], - "min_temp": 7.2, - "max_temp": 33.3, - "min_humidity": 20, - "max_humidity": 50, - }, - state="heat_cool", - ), - EntityTestInfo( - entity_id="binary_sensor.my_ecobee_occupancy", - friendly_name="My ecobee Occupancy", - unique_id="00:00:00:00:00:00_1_57", - unit_of_measurement=None, - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py deleted file mode 100644 index f9d19c5f9c1..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Regression tests for Ecobee occupancy. - -https://github.com/home-assistant/core/issues/31827 -""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_ecobee_occupancy_setup(hass: HomeAssistant) -> None: - """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ecobee_occupancy.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Master Fan", - model="ecobee Switch+", - manufacturer="ecobee Inc.", - sw_version="4.5.130201", - hw_version="", - serial_number="111111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="binary_sensor.master_fan", - friendly_name="Master Fan", - unique_id="00:00:00:00:00:00_1_56", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py deleted file mode 100644 index 10fcd8ede8e..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_eve_degree.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" -from homeassistant.components.number import NumberMode -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - PERCENTAGE, - EntityCategory, - UnitOfPressure, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_eve_degree_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "eve_degree.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Eve Degree AA11", - model="Eve Degree 00AAA0000", - manufacturer="Elgato", - sw_version="1.2.8", - hw_version="1.0.0", - serial_number="AA00A0A00000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_temperature", - unique_id="00:00:00:00:00:00_1_22", - friendly_name="Eve Degree AA11 Temperature", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="22.7719116210938", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_humidity", - unique_id="00:00:00:00:00:00_1_27", - friendly_name="Eve Degree AA11 Humidity", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="59.4818115234375", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_air_pressure", - unique_id="00:00:00:00:00:00_1_30_32", - friendly_name="Eve Degree AA11 Air Pressure", - unit_of_measurement=UnitOfPressure.HPA, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="1005.70001220703", - ), - EntityTestInfo( - entity_id="sensor.eve_degree_aa11_battery", - unique_id="00:00:00:00:00:00_1_17", - friendly_name="Eve Degree AA11 Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="65", - ), - EntityTestInfo( - entity_id="number.eve_degree_aa11_elevation", - unique_id="00:00:00:00:00:00_1_30_33", - friendly_name="Eve Degree AA11 Elevation", - capabilities={ - "max": 9000, - "min": -450, - "mode": NumberMode.AUTO, - "step": 1, - }, - state="0", - entity_category=EntityCategory.CONFIG, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_energy.py b/tests/components/homekit_controller/specific_devices/test_eve_energy.py deleted file mode 100644 index 5f8415c5074..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_eve_energy.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - ELECTRIC_CURRENT_AMPERE, - ELECTRIC_POTENTIAL_VOLT, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, - EntityCategory, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_eve_energy_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "eve_energy.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Eve Energy 50FF", - model="Eve Energy 20EAO8601", - manufacturer="Elgato", - sw_version="1.2.9", - hw_version="1.0.0", - serial_number="AA00A0A00000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.eve_energy_50ff", - unique_id="00:00:00:00:00:00_1_28", - friendly_name="Eve Energy 50FF", - state="off", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_amps", - unique_id="00:00:00:00:00:00_1_28_33", - friendly_name="Eve Energy 50FF Amps", - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_volts", - unique_id="00:00:00:00:00:00_1_28_32", - friendly_name="Eve Energy 50FF Volts", - unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0.400000005960464", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_power", - unique_id="00:00:00:00:00:00_1_28_34", - friendly_name="Eve Energy 50FF Power", - unit_of_measurement=POWER_WATT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="0", - ), - EntityTestInfo( - entity_id="sensor.eve_energy_50ff_energy_kwh", - unique_id="00:00:00:00:00:00_1_28_35", - friendly_name="Eve Energy 50FF Energy kWh", - capabilities={"state_class": SensorStateClass.TOTAL_INCREASING}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, - state="0.28999999165535", - ), - EntityTestInfo( - entity_id="switch.eve_energy_50ff_lock_physical_controls", - unique_id="00:00:00:00:00:00_1_28_36", - friendly_name="Eve Energy 50FF Lock Physical Controls", - entity_category=EntityCategory.CONFIG, - state="off", - ), - EntityTestInfo( - entity_id="button.eve_energy_50ff_identify", - unique_id="00:00:00:00:00:00_1_1_3", - friendly_name="Eve Energy 50FF Identify", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_haa_fan.py b/tests/components/homekit_controller/specific_devices/test_haa_fan.py deleted file mode 100644 index 07a7324032b..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_haa_fan.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Make sure that a H.A.A. fan can be setup.""" -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntityFeature -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_haa_fan_setup(hass: HomeAssistant) -> None: - """Test that a H.A.A. fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "haa_fan.json") - await setup_test_accessories(hass, accessories) - - haa_fan_state = hass.states.get("fan.haa_c718b3") - attributes = haa_fan_state.attributes - assert attributes[ATTR_PERCENTAGE] == 66 - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="HAA-C718B3", - model="RavenSystem HAA", - manufacturer="José A. Jiménez Campos", - sw_version="5.0.18", - hw_version="", - serial_number="C718B3-1", - devices=[ - DeviceTestInfo( - name="HAA-C718B3", - model="RavenSystem HAA", - manufacturer="José A. Jiménez Campos", - sw_version="5.0.18", - hw_version="", - serial_number="C718B3-2", - unique_id="00:00:00:00:00:00:aid:2", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.haa_c718b3", - friendly_name="HAA-C718B3", - unique_id="00:00:00:00:00:00_2_8", - state="off", - ) - ], - ), - ], - entities=[ - EntityTestInfo( - entity_id="fan.haa_c718b3", - friendly_name="HAA-C718B3", - unique_id="00:00:00:00:00:00_1_8", - state="on", - supported_features=FanEntityFeature.SET_SPEED, - capabilities={ - "preset_modes": None, - }, - ), - EntityTestInfo( - entity_id="button.haa_c718b3_setup", - friendly_name="HAA-C718B3 Setup", - unique_id="00:00:00:00:00:00_1_1010_1012", - entity_category=EntityCategory.CONFIG, - state="unknown", - ), - EntityTestInfo( - entity_id="button.haa_c718b3_update", - friendly_name="HAA-C718B3 Update", - unique_id="00:00:00:00:00:00_1_1010_1011", - entity_category=EntityCategory.CONFIG, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py deleted file mode 100644 index 84a14a8488d..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Test against characteristics captured from the Home Assistant HomeKit bridge running demo platforms.""" -from homeassistant.components.fan import FanEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_homeassistant_bridge_fan_setup(hass: HomeAssistant) -> None: - """Test that a SIMPLEconnect fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file( - hass, "home_assistant_bridge_fan.json" - ) - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Home Assistant Bridge", - model="Bridge", - manufacturer="Home Assistant", - sw_version="0.104.0.dev0", - hw_version="", - serial_number="homekit.bridge", - devices=[ - DeviceTestInfo( - name="Living Room Fan", - model="Fan", - manufacturer="Home Assistant", - sw_version="0.104.0.dev0", - hw_version="", - serial_number="fan.living_room_fan", - unique_id="00:00:00:00:00:00:aid:1256851357", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.living_room_fan", - friendly_name="Living Room Fan", - unique_id="00:00:00:00:00:00_1256851357_8", - supported_features=( - FanEntityFeature.DIRECTION - | FanEntityFeature.SET_SPEED - | FanEntityFeature.OSCILLATE - ), - capabilities={ - "preset_modes": None, - }, - state="off", - ) - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py deleted file mode 100644 index 5bb7003e58b..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Tests for handling accessories on a Homespan esp32 daikin bridge.""" -from homeassistant.components.climate import ClimateEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_homespan_daikin_bridge_setup(hass: HomeAssistant) -> None: - """Test that aHomespan esp32 daikin bridge can be correctly setup in HA via HomeKit.""" - accessories = await setup_accessories_from_file(hass, "homespan_daikin_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Air Conditioner", - model="Daikin-fwec3a-esp32-homekit-bridge", - manufacturer="Garzola Marco", - sw_version="1.0.0", - hw_version="1.0.0", - serial_number="00000001", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.air_conditioner_slaveid_1", - friendly_name="Air Conditioner SlaveID 1", - unique_id="00:00:00:00:00:00_1_9", - supported_features=( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - ), - capabilities={ - "hvac_modes": ["heat_cool", "heat", "cool", "off"], - "min_temp": 18, - "max_temp": 32, - "target_temp_step": 0.5, - "fan_modes": ["off", "low", "medium", "high"], - }, - state="cool", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index ca2392be4ce..e25d5b7830e 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -7,62 +7,16 @@ from aiohomekit.model import CharacteristicsTypes, ServicesTypes from aiohomekit.testing import FakePairing import pytest -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - Helper, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) +from ..common import Helper, setup_accessories_from_file, setup_test_accessories from tests.common import async_fire_time_changed LIGHT_ON = ("lightbulb", "on") -async def test_koogeek_ls1_setup(hass: HomeAssistant) -> None: - """Test that a Koogeek LS1 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Koogeek-LS1-20833F", - model="LS1", - manufacturer="Koogeek", - sw_version="2.2.15", - hw_version="", - serial_number="AAAA011111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.koogeek_ls1_20833f_light_strip", - friendly_name="Koogeek-LS1-20833F Light Strip", - unique_id="00:00:00:00:00:00_1_7", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="off", - ), - EntityTestInfo( - entity_id="button.koogeek_ls1_20833f_identify", - friendly_name="Koogeek-LS1-20833F Identify", - unique_id="00:00:00:00:00:00_1_1_6", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - ], - ), - ) - - @pytest.mark.parametrize("failure_cls", [AccessoryDisconnectedError, EncryptionError]) async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> None: """Test that entity actually recovers from a network connection drop. diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py deleted file mode 100644 index 91506382a8a..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Make sure that existing Koogeek P1EU support isn't broken.""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_koogeek_p1eu_setup(hass: HomeAssistant) -> None: - """Test that a Koogeek P1EU can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "koogeek_p1eu.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Koogeek-P1-A00AA0", - model="P1EU", - manufacturer="Koogeek", - sw_version="2.3.7", - hw_version="", - serial_number="EUCP03190xxxxx48", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.koogeek_p1_a00aa0_outlet", - friendly_name="Koogeek-P1-A00AA0 outlet", - unique_id="00:00:00:00:00:00_1_7", - state="off", - ), - EntityTestInfo( - entity_id="sensor.koogeek_p1_a00aa0_power", - friendly_name="Koogeek-P1-A00AA0 Power", - unique_id="00:00:00:00:00:00_1_21_22", - unit_of_measurement=POWER_WATT, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="5", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py deleted file mode 100644 index 4578014f009..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Regression tests for Aqara Gateway V3. - -https://github.com/home-assistant/core/issues/20885 -""" -from homeassistant.components.climate import ( - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lennox_e30_setup(hass: HomeAssistant) -> None: - """Test that a Lennox E30 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "lennox_e30.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Lennox", - model="E30 2B", - manufacturer="Lennox", - sw_version="3.40.XX", - hw_version="3.0.XX", - serial_number="XXXXXXXX", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.lennox", - friendly_name="Lennox", - unique_id="00:00:00:00:00:00_1_100", - supported_features=( - SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE - ), - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "max_temp": 37, - "min_temp": 4.5, - }, - state="heat_cool", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py deleted file mode 100644 index f35e7da2bdd..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Make sure that handling real world LG HomeKit characteristics isn't broken.""" -from homeassistant.components.media_player import MediaPlayerEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lg_tv(hass: HomeAssistant) -> None: - """Test that a Koogeek LS1 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "lg_tv.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="LG webOS TV AF80", - model="OLED55B9PUA", - manufacturer="LG Electronics", - sw_version="04.71.04", - hw_version="1", - serial_number="999AAAAAA999", - devices=[], - entities=[ - EntityTestInfo( - entity_id="media_player.lg_webos_tv_af80", - friendly_name="LG webOS TV AF80", - unique_id="00:00:00:00:00:00_1_48", - supported_features=( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.SELECT_SOURCE - ), - capabilities={ - "source_list": [ - "AirPlay", - "Live TV", - "HDMI 1", - "Sony", - "Apple", - "AV", - "HDMI 4", - ] - }, - # The LG TV doesn't (at least at this patch level) report - # its media state via CURRENT_MEDIA_STATE. Therefore "ok" - # is the best we can say. - state="on", - ), - ], - ), - ) - - """ - assert state.attributes["source"] == "HDMI 4" - """ diff --git a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py deleted file mode 100644 index 9cb65907e8a..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Tests for handling accessories on a Lutron Caseta bridge via HomeKit.""" -from homeassistant.const import STATE_OFF -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_lutron_caseta_bridge_setup(hass: HomeAssistant) -> None: - """Test that a Lutron Caseta bridge can be correctly setup in HA via HomeKit.""" - accessories = await setup_accessories_from_file(hass, "lutron_caseta_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Smart Bridge 2", - model="L-BDG2-WH", - manufacturer="Lutron Electronics Co., Inc", - sw_version="08.08", - hw_version="", - serial_number="12344331", - devices=[ - DeviceTestInfo( - name="Cas\u00e9ta\u00ae Wireless Fan Speed Control", - model="PD-FSQN-XX", - manufacturer="Lutron Electronics Co., Inc", - sw_version="001.005", - hw_version="", - serial_number="39024290", - unique_id="00:00:00:00:00:00:aid:21474836482", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.caseta_r_wireless_fan_speed_control", - friendly_name="Caséta® Wireless Fan Speed Control", - unique_id="00:00:00:00:00:00_21474836482_2", - unit_of_measurement=None, - supported_features=1, - state=STATE_OFF, - capabilities={"preset_modes": None}, - ) - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mss425f.py b/tests/components/homekit_controller/specific_devices/test_mss425f.py deleted file mode 100644 index 1ab608e3d2e..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_mss425f.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tests for the Meross MSS425f power strip.""" -from homeassistant.const import STATE_ON, STATE_UNKNOWN, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_meross_mss425f_setup(hass: HomeAssistant) -> None: - """Test that a MSS425f can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mss425f.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="MSS425F-15cc", - model="MSS425F", - manufacturer="Meross", - sw_version="4.2.3", - hw_version="4.0.0", - serial_number="HH41234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="button.mss425f_15cc_identify", - friendly_name="MSS425F-15cc Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state=STATE_UNKNOWN, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_1", - friendly_name="MSS425F-15cc Outlet-1", - unique_id="00:00:00:00:00:00_1_12", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_2", - friendly_name="MSS425F-15cc Outlet-2", - unique_id="00:00:00:00:00:00_1_15", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_3", - friendly_name="MSS425F-15cc Outlet-3", - unique_id="00:00:00:00:00:00_1_18", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_outlet_4", - friendly_name="MSS425F-15cc Outlet-4", - unique_id="00:00:00:00:00:00_1_21", - state=STATE_ON, - ), - EntityTestInfo( - entity_id="switch.mss425f_15cc_usb", - friendly_name="MSS425F-15cc USB", - unique_id="00:00:00:00:00:00_1_24", - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mss565.py b/tests/components/homekit_controller/specific_devices/test_mss565.py deleted file mode 100644 index 78d8d8f5250..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_mss565.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Tests for the Meross MSS565 wall switch.""" -from homeassistant.const import STATE_ON -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_meross_mss565_setup(hass: HomeAssistant) -> None: - """Test that a MSS565 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mss565.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="MSS565-28da", - model="MSS565", - manufacturer="Meross", - sw_version="4.1.9", - hw_version="4.0.0", - serial_number="BB1121", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.mss565_28da_dimmer_switch", - friendly_name="MSS565-28da Dimmer Switch", - unique_id="00:00:00:00:00:00_1_12", - capabilities={"supported_color_modes": ["brightness"]}, - state=STATE_ON, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py deleted file mode 100644 index 48828a2a6ad..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_mysa_living.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Make sure that Mysa Living is enumerated properly.""" -from homeassistant.components.climate import ClimateEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, UnitOfTemperature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_mysa_living_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "mysa_living.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Mysa-85dda9", - model="v1", - manufacturer="Empowered Homes Inc.", - sw_version="2.8.1", - hw_version="", - serial_number="AAAAAAA000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="climate.mysa_85dda9_thermostat", - friendly_name="Mysa-85dda9 Thermostat", - unique_id="00:00:00:00:00:00_1_20", - supported_features=ClimateEntityFeature.TARGET_TEMPERATURE, - capabilities={ - "hvac_modes": ["off", "heat", "cool", "heat_cool"], - "max_temp": 35, - "min_temp": 7, - }, - state="off", - ), - EntityTestInfo( - entity_id="sensor.mysa_85dda9_current_humidity", - friendly_name="Mysa-85dda9 Current Humidity", - unique_id="00:00:00:00:00:00_1_20_27", - unit_of_measurement=PERCENTAGE, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="40", - ), - EntityTestInfo( - entity_id="sensor.mysa_85dda9_current_temperature", - friendly_name="Mysa-85dda9 Current Temperature", - unique_id="00:00:00:00:00:00_1_20_25", - unit_of_measurement=UnitOfTemperature.CELSIUS, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - state="24.1", - ), - EntityTestInfo( - entity_id="light.mysa_85dda9_display", - friendly_name="Mysa-85dda9 Display", - unique_id="00:00:00:00:00:00_1_40", - supported_features=0, - capabilities={"supported_color_modes": ["brightness"]}, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py deleted file mode 100644 index 629059935cf..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Make sure that Nanoleaf NL55 works with BLE.""" -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - -LIGHT_ON = ("lightbulb", "on") - - -async def test_nanoleaf_nl55_setup(hass: HomeAssistant) -> None: - """Test that a Nanoleaf NL55 can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Nanoleaf Strip 3B32", - model="NL55", - manufacturer="Nanoleaf", - sw_version="1.4.40", - hw_version="1.2.4", - serial_number="AAAA011111111111", - devices=[], - entities=[ - EntityTestInfo( - entity_id="light.nanoleaf_strip_3b32_nanoleaf_light_strip", - friendly_name="Nanoleaf Strip 3B32 Nanoleaf Light Strip", - unique_id="00:00:00:00:00:00_1_19", - supported_features=0, - capabilities={ - "max_color_temp_kelvin": 6535, - "min_color_temp_kelvin": 2127, - "max_mireds": 470, - "min_mireds": 153, - "supported_color_modes": ["color_temp", "hs"], - }, - state="on", - ), - EntityTestInfo( - entity_id="button.nanoleaf_strip_3b32_identify", - friendly_name="Nanoleaf Strip 3B32 Identify", - unique_id="00:00:00:00:00:00_1_1_2", - entity_category=EntityCategory.DIAGNOSTIC, - state="unknown", - ), - EntityTestInfo( - entity_id="sensor.nanoleaf_strip_3b32_thread_capabilities", - friendly_name="Nanoleaf Strip 3B32 Thread Capabilities", - unique_id="00:00:00:00:00:00_1_31_115", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router_capable", - "full", - "minimal", - "none", - "router_eligible", - "sleepy", - ] - }, - state="border_router_capable", - ), - EntityTestInfo( - entity_id="sensor.nanoleaf_strip_3b32_thread_status", - friendly_name="Nanoleaf Strip 3B32 Thread Status", - unique_id="00:00:00:00:00:00_1_31_117", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={ - "options": [ - "border_router", - "child", - "detached", - "disabled", - "joining", - "leader", - "router", - ] - }, - state="border_router", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py deleted file mode 100644 index 71807871cc1..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Regression tests for Netamo Smart CO Alarm. - -https://github.com/home-assistant/core/issues/78903 -""" -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_netamo_smart_co_alarm_setup(hass: HomeAssistant) -> None: - """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "netamo_smart_co_alarm.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Smart CO Alarm", - model="Smart CO Alarm", - manufacturer="Netatmo", - sw_version="1.0.3", - hw_version="0", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="binary_sensor.smart_co_alarm_carbon_monoxide_sensor", - friendly_name="Smart CO Alarm Carbon Monoxide Sensor", - unique_id="00:00:00:00:00:00_1_22", - state="off", - ), - EntityTestInfo( - entity_id="binary_sensor.smart_co_alarm_low_battery", - friendly_name="Smart CO Alarm Low Battery", - entity_category=EntityCategory.DIAGNOSTIC, - unique_id="00:00:00:00:00:00_1_36", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py deleted file mode 100644 index e9e6749bd36..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Regression tests for Netamo Healthy Home Coach. - -https://github.com/home-assistant/core/issues/73360 -""" -from homeassistant.components.sensor import SensorStateClass -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_netamo_smart_co_alarm_setup(hass: HomeAssistant) -> None: - """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "netatmo_home_coach.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="Healthy Home Coach", - model="Healthy Home Coach", - manufacturer="Netatmo", - sw_version="59", - hw_version="", - serial_number="1234", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.healthy_home_coach_noise", - friendly_name="Healthy Home Coach Noise", - unique_id="00:00:00:00:00:00_1_20_21", - state="0", - unit_of_measurement="dB", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py deleted file mode 100644 index 24a4dbe0349..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Make sure that existing RainMachine support isn't broken. - -https://github.com/home-assistant/core/issues/31745 -""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_rainmachine_pro_8_setup(hass: HomeAssistant) -> None: - """Test that a RainMachine can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "rainmachine-pro-8.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RainMachine-00ce4a", - model="SPK5 Pro", - manufacturer="Green Electronics LLC", - sw_version="1.0.4", - hw_version="1", - serial_number="00aa0000aa0a", - devices=[], - entities=[ - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_512", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_2", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_768", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_3", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1024", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_4", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1280", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_5", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1536", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_6", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_1792", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_7", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_2048", - state="off", - ), - EntityTestInfo( - entity_id="switch.rainmachine_00ce4a_8", - friendly_name="RainMachine-00ce4a", - unique_id="00:00:00:00:00:00_1_2304", - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py deleted file mode 100644 index d56aa4ad481..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Test against characteristics captured from a ryse smart bridge platforms.""" -from homeassistant.components.cover import CoverEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - -RYSE_SUPPORTED_FEATURES = ( - CoverEntityFeature.CLOSE | CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN -) - - -async def test_ryse_smart_bridge_setup(hass: HomeAssistant) -> None: - """Test that a Ryse smart bridge can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "ryse_smart_bridge.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RYSE SmartBridge", - model="RYSE SmartBridge", - manufacturer="RYSE Inc.", - sw_version="1.3.0", - hw_version="0101.3521.0436", - devices=[ - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:2", - name="Master Bath South", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.master_bath_south_ryse_shade", - friendly_name="Master Bath South RYSE Shade", - unique_id="00:00:00:00:00:00_2_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.master_bath_south_ryse_shade_battery", - friendly_name="Master Bath South RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:3", - name="RYSE SmartShade", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="", - hw_version="", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.ryse_smartshade_ryse_shade", - friendly_name="RYSE SmartShade RYSE Shade", - unique_id="00:00:00:00:00:00_3_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.ryse_smartshade_ryse_shade_battery", - friendly_name="RYSE SmartShade RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_3_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - ], - entities=[], - ), - ) - - -async def test_ryse_smart_bridge_four_shades_setup(hass: HomeAssistant) -> None: - """Test that a Ryse smart bridge with four shades can be correctly setup in HA.""" - accessories = await setup_accessories_from_file( - hass, "ryse_smart_bridge_four_shades.json" - ) - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="RYSE SmartBridge", - model="RYSE SmartBridge", - manufacturer="RYSE Inc.", - sw_version="1.3.0", - hw_version="0401.3521.0679", - devices=[ - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:2", - name="LR Left", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.lr_left_ryse_shade", - friendly_name="LR Left RYSE Shade", - unique_id="00:00:00:00:00:00_2_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.lr_left_ryse_shade_battery", - friendly_name="LR Left RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_64", - unit_of_measurement=PERCENTAGE, - state="89", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:3", - name="LR Right", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.lr_right_ryse_shade", - friendly_name="LR Right RYSE Shade", - unique_id="00:00:00:00:00:00_3_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="closed", - ), - EntityTestInfo( - entity_id="sensor.lr_right_ryse_shade_battery", - friendly_name="LR Right RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_3_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:4", - name="BR Left", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.br_left_ryse_shade", - friendly_name="BR Left RYSE Shade", - unique_id="00:00:00:00:00:00_4_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.br_left_ryse_shade_battery", - friendly_name="BR Left RYSE Shade Battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_4_64", - unit_of_measurement=PERCENTAGE, - state="100", - ), - ], - ), - DeviceTestInfo( - unique_id="00:00:00:00:00:00:aid:5", - name="RZSS", - model="RYSE Shade", - manufacturer="RYSE Inc.", - sw_version="3.0.8", - hw_version="1.0.0", - serial_number="", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.rzss_ryse_shade", - friendly_name="RZSS RYSE Shade", - unique_id="00:00:00:00:00:00_5_48", - supported_features=RYSE_SUPPORTED_FEATURES, - state="open", - ), - EntityTestInfo( - entity_id="sensor.rzss_ryse_shade_battery", - entity_category=EntityCategory.DIAGNOSTIC, - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - friendly_name="RZSS RYSE Shade Battery", - unique_id="00:00:00:00:00:00_5_64", - unit_of_measurement=PERCENTAGE, - state="0", - ), - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py deleted file mode 100644 index 6ed0a97c23d..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Make sure that Schlage Sense is enumerated properly.""" -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_schlage_sense_setup(hass: HomeAssistant) -> None: - """Test that the accessory can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "schlage_sense.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="SENSE ", - model="BE479CAM619", - manufacturer="Schlage ", - sw_version="004.027.000", - hw_version="1.3.0", - serial_number="AAAAAAA000", - devices=[], - entities=[ - EntityTestInfo( - entity_id="lock.sense_lock_mechanism", - friendly_name="SENSE Lock Mechanism", - unique_id="00:00:00:00:00:00_1_30", - supported_features=0, - state="unknown", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py deleted file mode 100644 index 59e7d2855e4..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Test against characteristics captured from a SIMPLEconnect Fan. - -https://github.com/home-assistant/core/issues/26180 -""" -from homeassistant.components.fan import FanEntityFeature -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_simpleconnect_fan_setup(hass: HomeAssistant) -> None: - """Test that a SIMPLEconnect fan can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "simpleconnect_fan.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="SIMPLEconnect Fan-06F674", - model="SIMPLEconnect", - manufacturer="Hunter Fan", - sw_version="", - hw_version="", - serial_number="1234567890abcd", - devices=[], - entities=[ - EntityTestInfo( - entity_id="fan.simpleconnect_fan_06f674_hunter_fan", - friendly_name="SIMPLEconnect Fan-06F674 Hunter Fan", - unique_id="00:00:00:00:00:00_1_8", - supported_features=FanEntityFeature.DIRECTION - | FanEntityFeature.SET_SPEED, - capabilities={ - "preset_modes": None, - }, - state="off", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py deleted file mode 100644 index 854de4b89d8..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test against characteristics captured from a Velux Gateway. - -https://github.com/home-assistant/core/issues/44314 -""" -from homeassistant.components.cover import CoverEntityFeature -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - CONCENTRATION_PARTS_PER_MILLION, - PERCENTAGE, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_velux_cover_setup(hass: HomeAssistant) -> None: - """Test that a velux gateway can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "velux_gateway.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="VELUX Gateway", - model="VELUX Gateway", - manufacturer="VELUX", - sw_version="70", - hw_version="", - serial_number="a1a11a1", - devices=[ - DeviceTestInfo( - name="VELUX Window", - model="VELUX Window", - manufacturer="VELUX", - sw_version="48", - hw_version="", - serial_number="1111111a114a111a", - unique_id="00:00:00:00:00:00:aid:3", - devices=[], - entities=[ - EntityTestInfo( - entity_id="cover.velux_window_roof_window", - friendly_name="VELUX Window Roof Window", - unique_id="00:00:00:00:00:00_3_8", - supported_features=CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN, - state="closed", - ), - ], - ), - DeviceTestInfo( - name="VELUX Sensor", - model="VELUX Sensor", - manufacturer="VELUX", - sw_version="16", - hw_version="", - serial_number="a11b111", - unique_id="00:00:00:00:00:00:aid:2", - devices=[], - entities=[ - EntityTestInfo( - entity_id="sensor.velux_sensor_temperature_sensor", - friendly_name="VELUX Sensor Temperature sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_8", - unit_of_measurement=UnitOfTemperature.CELSIUS, - state="18.9", - ), - EntityTestInfo( - entity_id="sensor.velux_sensor_humidity_sensor", - friendly_name="VELUX Sensor Humidity sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_11", - unit_of_measurement=PERCENTAGE, - state="58", - ), - EntityTestInfo( - entity_id="sensor.velux_sensor_carbon_dioxide_sensor", - friendly_name="VELUX Sensor Carbon Dioxide sensor", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unique_id="00:00:00:00:00:00_2_14", - unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - state="400", - ), - ], - ), - ], - entities=[], - ), - ) diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py deleted file mode 100644 index fed8f05b0b9..00000000000 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Make sure that Vocolinc Flowerbud is enumerated properly.""" -from homeassistant.components.humidifier import HumidifierEntityFeature -from homeassistant.components.number import NumberMode -from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant - -from ..common import ( - HUB_TEST_ACCESSORY_ID, - DeviceTestInfo, - EntityTestInfo, - assert_devices_and_entities_created, - setup_accessories_from_file, - setup_test_accessories, -) - - -async def test_vocolinc_flowerbud_setup(hass: HomeAssistant) -> None: - """Test that a Vocolinc Flowerbud can be correctly setup in HA.""" - accessories = await setup_accessories_from_file(hass, "vocolinc_flowerbud.json") - await setup_test_accessories(hass, accessories) - - await assert_devices_and_entities_created( - hass, - DeviceTestInfo( - unique_id=HUB_TEST_ACCESSORY_ID, - name="VOCOlinc-Flowerbud-0d324b", - model="Flowerbud", - manufacturer="VOCOlinc", - sw_version="3.121.2", - hw_version="0.1", - serial_number="AM01121849000327", - devices=[], - entities=[ - EntityTestInfo( - entity_id="humidifier.vocolinc_flowerbud_0d324b", - friendly_name="VOCOlinc-Flowerbud-0d324b", - unique_id="00:00:00:00:00:00_1_30", - supported_features=HumidifierEntityFeature.MODES, - capabilities={ - "available_modes": ["normal", "auto"], - "max_humidity": 100.0, - "min_humidity": 0.0, - }, - state="off", - ), - EntityTestInfo( - entity_id="light.vocolinc_flowerbud_0d324b_mood_light", - friendly_name="VOCOlinc-Flowerbud-0d324b Mood Light", - unique_id="00:00:00:00:00:00_1_9", - supported_features=0, - capabilities={"supported_color_modes": ["hs"]}, - state="on", - ), - EntityTestInfo( - entity_id="number.vocolinc_flowerbud_0d324b_spray_quantity", - friendly_name="VOCOlinc-Flowerbud-0d324b Spray Quantity", - unique_id="00:00:00:00:00:00_1_30_38", - capabilities={ - "max": 5, - "min": 1, - "mode": NumberMode.AUTO, - "step": 1, - }, - state="5", - entity_category=EntityCategory.CONFIG, - ), - EntityTestInfo( - entity_id="sensor.vocolinc_flowerbud_0d324b_current_humidity", - friendly_name="VOCOlinc-Flowerbud-0d324b Current Humidity", - unique_id="00:00:00:00:00:00_1_30_33", - capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=PERCENTAGE, - state="45.0", - ), - ], - ), - ) diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 8ffeec093f6..23c6e245ac7 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -1,5 +1,6 @@ """Tests for homekit_controller init.""" from datetime import timedelta +import pathlib from unittest.mock import patch from aiohomekit import AccessoryNotFoundError @@ -7,6 +8,9 @@ from aiohomekit.model import Accessory, Transport from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes from aiohomekit.testing import FakePairing +from attr import asdict +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homekit_controller.const import DOMAIN, ENTITY_MAP from homeassistant.config_entries import ConfigEntryState @@ -20,6 +24,8 @@ from homeassistant.util.dt import utcnow from .common import ( Helper, remove_device, + setup_accessories_from_file, + setup_test_accessories, setup_test_accessories_with_controller, setup_test_component, ) @@ -27,6 +33,9 @@ from .common import ( from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" +FIXTURES = [path.relative_to(FIXTURES_DIR) for path in FIXTURES_DIR.glob("*.json")] + ALIVE_DEVICE_NAME = "testdevice" ALIVE_DEVICE_ENTITY_ID = "light.testdevice" @@ -218,3 +227,57 @@ async def test_ble_device_only_checks_is_available( is_available = True async_fire_time_changed(hass, utcnow() + timedelta(hours=1)) assert hass.states.get("light.testdevice").state == STATE_OFF + + +@pytest.mark.parametrize("example", FIXTURES, ids=lambda val: str(val.stem)) +async def test_snapshots( + hass: HomeAssistant, snapshot: SnapshotAssertion, example: str +) -> None: + """Detect regressions in enumerating a homekit accessory database and building entities.""" + accessories = await setup_accessories_from_file(hass, example) + config_entry, _ = await setup_test_accessories(hass, accessories) + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + registry_devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + registry_devices.sort(key=lambda device: device.name) + + devices = [] + + for device in registry_devices: + entities = [] + + registry_entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + registry_entities.sort(key=lambda entity: entity.entity_id) + + for entity_entry in registry_entities: + state_dict = None + if state := hass.states.get(entity_entry.entity_id): + state_dict = dict(state.as_dict()) + state_dict.pop("context", None) + state_dict.pop("last_changed", None) + state_dict.pop("last_updated", None) + + state_dict["attributes"] = dict(state_dict["attributes"]) + state_dict["attributes"].pop("access_token", None) + state_dict["attributes"].pop("entity_picture", None) + + entry = asdict(entity_entry) + entry.pop("id", None) + entry.pop("device_id", None) + + entities.append({"entry": entry, "state": state_dict}) + + device_dict = asdict(device) + device_dict.pop("id", None) + device_dict.pop("via_device_id", None) + devices.append({"device": device_dict, "entities": entities}) + + assert snapshot == devices From 9fe6cd61df718e8d771c91e83aa661fc0234972c Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 25 Sep 2023 23:08:38 +0100 Subject: [PATCH 793/984] Bump aiohomekit to 3.0.5 (#100886) --- homeassistant/components/homekit_controller/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_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 877c687f33e..5687cd4dba3 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.4"], + "requirements": ["aiohomekit==3.0.5"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 64f23e8d4a6..506cc9a5f96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -249,7 +249,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.4 +aiohomekit==3.0.5 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 096669866b3..62eade4c102 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.4 +aiohomekit==3.0.5 # homeassistant.components.emulated_hue # homeassistant.components.http From a4f7f3ba7ee0b9d5b4f206952b0c0b3e2fefc360 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 26 Sep 2023 00:32:12 +0200 Subject: [PATCH 794/984] Make sure time is changed in mqtt event test (#100889) --- tests/components/mqtt/test_event.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 401caac8007..37a17ac9a41 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -706,10 +706,12 @@ async def test_skipped_async_ha_write_state( await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) +@pytest.mark.freeze_time("2023-09-01 00:00:00+00:00") @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_skipped_async_ha_write_state2( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test a write state command is only called when there is a valid event.""" await mqtt_mock_entry() @@ -724,14 +726,17 @@ async def test_skipped_async_ha_write_state2( await hass.async_block_till_done() assert len(mock_async_ha_write_state.mock_calls) == 1 + freezer.move_to("2023-09-01 00:00:10+00:00") async_fire_mqtt_message(hass, topic, payload1) await hass.async_block_till_done() assert len(mock_async_ha_write_state.mock_calls) == 2 + freezer.move_to("2023-09-01 00:00:20+00:00") async_fire_mqtt_message(hass, topic, payload2) await hass.async_block_till_done() assert len(mock_async_ha_write_state.mock_calls) == 2 + freezer.move_to("2023-09-01 00:00:30+00:00") async_fire_mqtt_message(hass, topic, payload2) await hass.async_block_till_done() assert len(mock_async_ha_write_state.mock_calls) == 2 From 785618909aadc604f227e57dfe6e8eb060105ce3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 25 Sep 2023 19:03:50 -0500 Subject: [PATCH 795/984] Use webrtc-noise-gain for audio enhancement in Assist pipelines (#100698) * Use webrtc-noise-gain instead of webrtcvad package * Switching to ProcessedAudioChunk * Refactor VAD and fix tests * Add vad no chunking test * Add test that runs audio enhancements --- .../components/assist_pipeline/__init__.py | 4 + .../components/assist_pipeline/manifest.json | 2 +- .../components/assist_pipeline/pipeline.py | 271 +++++++++++++++--- .../components/assist_pipeline/vad.py | 175 ++++++----- .../assist_pipeline/websocket_api.py | 24 +- homeassistant/components/voip/voip.py | 21 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../assist_pipeline/snapshots/test_init.ambr | 12 - .../snapshots/test_websocket.ambr | 81 ++++++ tests/components/assist_pipeline/test_init.py | 90 +++--- tests/components/assist_pipeline/test_vad.py | 157 ++++++---- .../assist_pipeline/test_websocket.py | 114 +++++++- tests/components/voip/test_voip.py | 8 +- 15 files changed, 707 insertions(+), 258 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 7f87bd254d0..9a61346f673 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DATA_CONFIG, DOMAIN from .error import PipelineNotFound from .pipeline import ( + AudioSettings, Pipeline, PipelineEvent, PipelineEventCallback, @@ -33,6 +34,7 @@ __all__ = ( "async_get_pipelines", "async_setup", "async_pipeline_from_audio_stream", + "AudioSettings", "Pipeline", "PipelineEvent", "PipelineEventType", @@ -71,6 +73,7 @@ async def async_pipeline_from_audio_stream( conversation_id: str | None = None, tts_audio_output: str | None = None, wake_word_settings: WakeWordSettings | None = None, + audio_settings: AudioSettings | None = None, device_id: str | None = None, start_stage: PipelineStage = PipelineStage.STT, end_stage: PipelineStage = PipelineStage.TTS, @@ -93,6 +96,7 @@ async def async_pipeline_from_audio_stream( event_callback=event_callback, tts_audio_output=tts_audio_output, wake_word_settings=wake_word_settings, + audio_settings=audio_settings or AudioSettings(), ), ) await pipeline_input.validate() diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 1db415b29d2..1034d1b5f62 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["webrtcvad==2.0.10"] + "requirements": ["webrtc-noise-gain==1.1.0"] } diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index e3b0eafda20..89bb9736737 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1,7 +1,9 @@ """Classes for voice assistant pipelines.""" from __future__ import annotations +import array import asyncio +from collections import deque from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field from enum import StrEnum @@ -10,10 +12,11 @@ from pathlib import Path from queue import Queue from threading import Thread import time -from typing import Any, cast +from typing import Any, Final, cast import wave import voluptuous as vol +from webrtc_noise_gain import AudioProcessor from homeassistant.components import ( conversation, @@ -54,8 +57,7 @@ from .error import ( WakeWordDetectionError, WakeWordTimeoutError, ) -from .ring_buffer import RingBuffer -from .vad import VoiceActivityTimeout, VoiceCommandSegmenter +from .vad import AudioBuffer, VoiceActivityTimeout, VoiceCommandSegmenter, chunk_samples _LOGGER = logging.getLogger(__name__) @@ -95,6 +97,9 @@ STORED_PIPELINE_RUNS = 10 SAVE_DELAY = 10 +AUDIO_PROCESSOR_SAMPLES: Final = 160 # 10 ms @ 16 Khz +AUDIO_PROCESSOR_BYTES: Final = AUDIO_PROCESSOR_SAMPLES * 2 # 16-bit samples + async def _async_resolve_default_pipeline_settings( hass: HomeAssistant, @@ -393,6 +398,60 @@ class WakeWordSettings: """Seconds of audio to buffer before detection and forward to STT.""" +@dataclass(frozen=True) +class AudioSettings: + """Settings for pipeline audio processing.""" + + noise_suppression_level: int = 0 + """Level of noise suppression (0 = disabled, 4 = max)""" + + auto_gain_dbfs: int = 0 + """Amount of automatic gain in dbFS (0 = disabled, 31 = max)""" + + volume_multiplier: float = 1.0 + """Multiplier used directly on PCM samples (1.0 = no change, 2.0 = twice as loud)""" + + is_vad_enabled: bool = True + """True if VAD is used to determine the end of the voice command.""" + + is_chunking_enabled: bool = True + """True if audio is automatically split into 10 ms chunks (required for VAD, etc.)""" + + def __post_init__(self) -> None: + """Verify settings post-initialization.""" + if (self.noise_suppression_level < 0) or (self.noise_suppression_level > 4): + raise ValueError("noise_suppression_level must be in [0, 4]") + + if (self.auto_gain_dbfs < 0) or (self.auto_gain_dbfs > 31): + raise ValueError("auto_gain_dbfs must be in [0, 31]") + + if self.needs_processor and (not self.is_chunking_enabled): + raise ValueError("Chunking must be enabled for audio processing") + + @property + def needs_processor(self) -> bool: + """True if an audio processor is needed.""" + return ( + self.is_vad_enabled + or (self.noise_suppression_level > 0) + or (self.auto_gain_dbfs > 0) + ) + + +@dataclass(frozen=True, slots=True) +class ProcessedAudioChunk: + """Processed audio chunk and metadata.""" + + audio: bytes + """Raw PCM audio @ 16Khz with 16-bit mono samples""" + + timestamp_ms: int + """Timestamp relative to start of audio stream (milliseconds)""" + + is_speech: bool | None + """True if audio chunk likely contains speech, False if not, None if unknown""" + + @dataclass class PipelineRun: """Running context for a pipeline.""" @@ -408,6 +467,7 @@ class PipelineRun: intent_agent: str | None = None tts_audio_output: str | None = None wake_word_settings: WakeWordSettings | None = None + audio_settings: AudioSettings = field(default_factory=AudioSettings) id: str = field(default_factory=ulid_util.ulid) stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False) @@ -422,6 +482,12 @@ class PipelineRun: debug_recording_queue: Queue[str | bytes | None] | None = None """Queue to communicate with debug recording thread""" + audio_processor: AudioProcessor | None = None + """VAD/noise suppression/auto gain""" + + audio_processor_buffer: AudioBuffer = field(init=False) + """Buffer used when splitting audio into chunks for audio processing""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -439,6 +505,14 @@ class PipelineRun: ) pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug() + # Initialize with audio settings + self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES) + if self.audio_settings.needs_processor: + self.audio_processor = AudioProcessor( + self.audio_settings.auto_gain_dbfs, + self.audio_settings.noise_suppression_level, + ) + @callback def process_event(self, event: PipelineEvent) -> None: """Log an event and call listener.""" @@ -499,8 +573,8 @@ class PipelineRun: async def wake_word_detection( self, - stream: AsyncIterable[bytes], - audio_chunks_for_stt: list[bytes], + stream: AsyncIterable[ProcessedAudioChunk], + audio_chunks_for_stt: list[ProcessedAudioChunk], ) -> wake_word.DetectionResult | None: """Run wake-word-detection portion of pipeline. Returns detection result.""" metadata_dict = asdict( @@ -541,12 +615,13 @@ class PipelineRun: # Audio chunk buffer. This audio will be forwarded to speech-to-text # after wake-word-detection. - num_audio_bytes_to_buffer = int( - wake_word_settings.audio_seconds_to_buffer * 16000 * 2 # 16-bit @ 16Khz + num_audio_chunks_to_buffer = int( + (wake_word_settings.audio_seconds_to_buffer * 16000) + / AUDIO_PROCESSOR_SAMPLES ) - stt_audio_buffer: RingBuffer | None = None - if num_audio_bytes_to_buffer > 0: - stt_audio_buffer = RingBuffer(num_audio_bytes_to_buffer) + stt_audio_buffer: deque[ProcessedAudioChunk] | None = None + if num_audio_chunks_to_buffer > 0: + stt_audio_buffer = deque(maxlen=num_audio_chunks_to_buffer) try: # Detect wake word(s) @@ -562,7 +637,7 @@ class PipelineRun: if stt_audio_buffer is not None: # All audio kept from right before the wake word was detected as # a single chunk. - audio_chunks_for_stt.append(stt_audio_buffer.getvalue()) + audio_chunks_for_stt.extend(stt_audio_buffer) except WakeWordTimeoutError: _LOGGER.debug("Timeout during wake word detection") raise @@ -586,7 +661,11 @@ class PipelineRun: # speech-to-text so the user does not have to pause before # speaking the voice command. for chunk_ts in result.queued_audio: - audio_chunks_for_stt.append(chunk_ts[0]) + audio_chunks_for_stt.append( + ProcessedAudioChunk( + audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False + ) + ) wake_word_output = asdict(result) @@ -604,8 +683,8 @@ class PipelineRun: async def _wake_word_audio_stream( self, - audio_stream: AsyncIterable[bytes], - stt_audio_buffer: RingBuffer | None, + audio_stream: AsyncIterable[ProcessedAudioChunk], + stt_audio_buffer: deque[ProcessedAudioChunk] | None, wake_word_vad: VoiceActivityTimeout | None, sample_rate: int = 16000, sample_width: int = 2, @@ -615,25 +694,24 @@ class PipelineRun: Adds audio to a ring buffer that will be forwarded to speech-to-text after detection. Times out if VAD detects enough silence. """ - ms_per_sample = sample_rate // 1000 - timestamp_ms = 0 + chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate async for chunk in audio_stream: if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk) + self.debug_recording_queue.put_nowait(chunk.audio) - yield chunk, timestamp_ms - timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + yield chunk.audio, chunk.timestamp_ms # Wake-word-detection occurs *after* the wake word was actually # spoken. Keeping audio right before detection allows the voice # command to be spoken immediately after the wake word. if stt_audio_buffer is not None: - stt_audio_buffer.put(chunk) + stt_audio_buffer.append(chunk) - if (wake_word_vad is not None) and (not wake_word_vad.process(chunk)): - raise WakeWordTimeoutError( - code="wake-word-timeout", message="Wake word was not detected" - ) + if wake_word_vad is not None: + if not wake_word_vad.process(chunk_seconds, chunk.is_speech): + raise WakeWordTimeoutError( + code="wake-word-timeout", message="Wake word was not detected" + ) async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: """Prepare speech-to-text.""" @@ -666,7 +744,7 @@ class PipelineRun: async def speech_to_text( self, metadata: stt.SpeechMetadata, - stream: AsyncIterable[bytes], + stream: AsyncIterable[ProcessedAudioChunk], ) -> str: """Run speech-to-text portion of pipeline. Returns the spoken text.""" if isinstance(self.stt_provider, stt.Provider): @@ -690,11 +768,13 @@ class PipelineRun: try: # Transcribe audio stream + stt_vad: VoiceCommandSegmenter | None = None + if self.audio_settings.is_vad_enabled: + stt_vad = VoiceCommandSegmenter() + result = await self.stt_provider.async_process_audio_stream( metadata, - self._speech_to_text_stream( - audio_stream=stream, stt_vad=VoiceCommandSegmenter() - ), + self._speech_to_text_stream(audio_stream=stream, stt_vad=stt_vad), ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech-to-text") @@ -731,26 +811,25 @@ class PipelineRun: async def _speech_to_text_stream( self, - audio_stream: AsyncIterable[bytes], + audio_stream: AsyncIterable[ProcessedAudioChunk], stt_vad: VoiceCommandSegmenter | None, sample_rate: int = 16000, sample_width: int = 2, ) -> AsyncGenerator[bytes, None]: """Yield audio chunks until VAD detects silence or speech-to-text completes.""" - ms_per_sample = sample_rate // 1000 + chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate sent_vad_start = False - timestamp_ms = 0 async for chunk in audio_stream: if self.debug_recording_queue is not None: - self.debug_recording_queue.put_nowait(chunk) + self.debug_recording_queue.put_nowait(chunk.audio) if stt_vad is not None: - if not stt_vad.process(chunk): + if not stt_vad.process(chunk_seconds, chunk.is_speech): # Silence detected at the end of voice command self.process_event( PipelineEvent( PipelineEventType.STT_VAD_END, - {"timestamp": timestamp_ms}, + {"timestamp": chunk.timestamp_ms}, ) ) break @@ -760,13 +839,12 @@ class PipelineRun: self.process_event( PipelineEvent( PipelineEventType.STT_VAD_START, - {"timestamp": timestamp_ms}, + {"timestamp": chunk.timestamp_ms}, ) ) sent_vad_start = True - yield chunk - timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + yield chunk.audio async def prepare_recognize_intent(self) -> None: """Prepare recognizing an intent.""" @@ -977,6 +1055,94 @@ class PipelineRun: self.debug_recording_queue = None self.debug_recording_thread = None + async def process_volume_only( + self, + audio_stream: AsyncIterable[bytes], + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncGenerator[ProcessedAudioChunk, None]: + """Apply volume transformation only (no VAD/audio enhancements) with optional chunking.""" + ms_per_sample = sample_rate // 1000 + ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample + timestamp_ms = 0 + + async for chunk in audio_stream: + if self.audio_settings.volume_multiplier != 1.0: + chunk = _multiply_volume(chunk, self.audio_settings.volume_multiplier) + + if self.audio_settings.is_chunking_enabled: + # 10 ms chunking + for chunk_10ms in chunk_samples( + chunk, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer + ): + yield ProcessedAudioChunk( + audio=chunk_10ms, + timestamp_ms=timestamp_ms, + is_speech=None, # no VAD + ) + timestamp_ms += ms_per_chunk + else: + # No chunking + yield ProcessedAudioChunk( + audio=chunk, + timestamp_ms=timestamp_ms, + is_speech=None, # no VAD + ) + timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + + async def process_enhance_audio( + self, + audio_stream: AsyncIterable[bytes], + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncGenerator[ProcessedAudioChunk, None]: + """Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation.""" + assert self.audio_processor is not None + + ms_per_sample = sample_rate // 1000 + ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample + timestamp_ms = 0 + + async for dirty_samples in audio_stream: + if self.audio_settings.volume_multiplier != 1.0: + # Static gain + dirty_samples = _multiply_volume( + dirty_samples, self.audio_settings.volume_multiplier + ) + + # Split into 10ms chunks for audio enhancements/VAD + for dirty_10ms_chunk in chunk_samples( + dirty_samples, AUDIO_PROCESSOR_BYTES, self.audio_processor_buffer + ): + ap_result = self.audio_processor.Process10ms(dirty_10ms_chunk) + yield ProcessedAudioChunk( + audio=ap_result.audio, + timestamp_ms=timestamp_ms, + is_speech=ap_result.is_speech, + ) + + timestamp_ms += ms_per_chunk + + +def _multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes: + """Multiplies 16-bit PCM samples by a constant.""" + return array.array( + "h", + [ + int( + # Clamp to signed 16-bit range + max( + -32767, + min( + 32767, + value * volume_multiplier, + ), + ) + ) + for value in array.array("h", chunk) + ], + ).tobytes() + def _pipeline_debug_recording_thread_proc( run_recording_dir: Path, @@ -1042,14 +1208,23 @@ class PipelineInput: """Run pipeline.""" self.run.start(device_id=self.device_id) current_stage: PipelineStage | None = self.run.start_stage - stt_audio_buffer: list[bytes] = [] + stt_audio_buffer: list[ProcessedAudioChunk] = [] + stt_processed_stream: AsyncIterable[ProcessedAudioChunk] | None = None + + if self.stt_stream is not None: + if self.run.audio_settings.needs_processor: + # VAD/noise suppression/auto gain/volume + stt_processed_stream = self.run.process_enhance_audio(self.stt_stream) + else: + # Volume multiplier only + stt_processed_stream = self.run.process_volume_only(self.stt_stream) try: if current_stage == PipelineStage.WAKE_WORD: # wake-word-detection - assert self.stt_stream is not None + assert stt_processed_stream is not None detect_result = await self.run.wake_word_detection( - self.stt_stream, stt_audio_buffer + stt_processed_stream, stt_audio_buffer ) if detect_result is None: # No wake word. Abort the rest of the pipeline. @@ -1062,28 +1237,30 @@ class PipelineInput: intent_input = self.intent_input if current_stage == PipelineStage.STT: assert self.stt_metadata is not None - assert self.stt_stream is not None + assert stt_processed_stream is not None - stt_stream = self.stt_stream + stt_input_stream = stt_processed_stream if stt_audio_buffer: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. - async def buffer_then_audio_stream() -> AsyncGenerator[bytes, None]: + async def buffer_then_audio_stream() -> AsyncGenerator[ + ProcessedAudioChunk, None + ]: # Buffered audio for chunk in stt_audio_buffer: yield chunk # Streamed audio - assert self.stt_stream is not None - async for chunk in self.stt_stream: + assert stt_processed_stream is not None + async for chunk in stt_processed_stream: yield chunk - stt_stream = buffer_then_audio_stream() + stt_input_stream = buffer_then_audio_stream() intent_input = await self.run.speech_to_text( self.stt_metadata, - stt_stream, + stt_input_stream, ) current_stage = PipelineStage.INTENT diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index 20a048d5621..30fad1c80d6 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -1,12 +1,13 @@ """Voice activity detection.""" from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Iterable -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import StrEnum -from typing import Final +from typing import Final, cast -import webrtcvad +from webrtc_noise_gain import AudioProcessor _SAMPLE_RATE: Final = 16000 # Hz _SAMPLE_WIDTH: Final = 2 # bytes @@ -32,6 +33,38 @@ class VadSensitivity(StrEnum): return 1.0 +class VoiceActivityDetector(ABC): + """Base class for voice activity detectors (VAD).""" + + @abstractmethod + def is_speech(self, chunk: bytes) -> bool: + """Return True if audio chunk contains speech.""" + + @property + @abstractmethod + def samples_per_chunk(self) -> int | None: + """Return number of samples per chunk or None if chunking is not required.""" + + +class WebRtcVad(VoiceActivityDetector): + """Voice activity detector based on webrtc.""" + + def __init__(self) -> None: + """Initialize webrtcvad.""" + # Just VAD: no noise suppression or auto gain + self._audio_processor = AudioProcessor(0, 0) + + def is_speech(self, chunk: bytes) -> bool: + """Return True if audio chunk contains speech.""" + result = self._audio_processor.Process10ms(chunk) + return cast(bool, result.is_speech) + + @property + def samples_per_chunk(self) -> int | None: + """Return 10 ms.""" + return int(0.01 * _SAMPLE_RATE) # 10 ms + + class AudioBuffer: """Fixed-sized audio buffer with variable internal length.""" @@ -73,13 +106,7 @@ class AudioBuffer: @dataclass class VoiceCommandSegmenter: - """Segments an audio stream into voice commands using webrtcvad.""" - - vad_mode: int = 3 - """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - - vad_samples_per_chunk: int = 480 # 30 ms - """Must be 10, 20, or 30 ms at 16Khz.""" + """Segments an audio stream into voice commands.""" speech_seconds: float = 0.3 """Seconds of speech before voice command has started.""" @@ -108,85 +135,85 @@ class VoiceCommandSegmenter: _reset_seconds_left: float = 0.0 """Seconds left before resetting start/stop time counters.""" - _vad: webrtcvad.Vad = None - _leftover_chunk_buffer: AudioBuffer = field(init=False) - _bytes_per_chunk: int = field(init=False) - _seconds_per_chunk: float = field(init=False) - def __post_init__(self) -> None: - """Initialize VAD.""" - self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH - self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE - self._leftover_chunk_buffer = AudioBuffer( - self.vad_samples_per_chunk * _SAMPLE_WIDTH - ) + """Reset after initialization.""" self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._leftover_chunk_buffer.clear() self._speech_seconds_left = self.speech_seconds self._silence_seconds_left = self.silence_seconds self._timeout_seconds_left = self.timeout_seconds self._reset_seconds_left = self.reset_seconds self.in_command = False - def process(self, samples: bytes) -> bool: - """Process 16-bit 16Khz mono audio samples. + def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + """Process samples using external VAD. Returns False when command is done. """ - for chunk in chunk_samples( - samples, self._bytes_per_chunk, self._leftover_chunk_buffer - ): - if not self._process_chunk(chunk): - self.reset() - return False - - return True - - @property - def audio_buffer(self) -> bytes: - """Get partial chunk in the audio buffer.""" - return self._leftover_chunk_buffer.bytes() - - def _process_chunk(self, chunk: bytes) -> bool: - """Process a single chunk of 16-bit 16Khz mono audio. - - Returns False when command is done. - """ - is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) - - self._timeout_seconds_left -= self._seconds_per_chunk + self._timeout_seconds_left -= chunk_seconds if self._timeout_seconds_left <= 0: + self.reset() return False if not self.in_command: if is_speech: self._reset_seconds_left = self.reset_seconds - self._speech_seconds_left -= self._seconds_per_chunk + self._speech_seconds_left -= chunk_seconds if self._speech_seconds_left <= 0: # Inside voice command self.in_command = True else: # Reset if enough silence - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: self._speech_seconds_left = self.speech_seconds elif not is_speech: self._reset_seconds_left = self.reset_seconds - self._silence_seconds_left -= self._seconds_per_chunk + self._silence_seconds_left -= chunk_seconds if self._silence_seconds_left <= 0: + self.reset() return False else: # Reset if enough speech - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: self._silence_seconds_left = self.silence_seconds return True + def process_with_vad( + self, + chunk: bytes, + vad: VoiceActivityDetector, + leftover_chunk_buffer: AudioBuffer | None, + ) -> bool: + """Process an audio chunk using an external VAD. + + A buffer is required if the VAD requires fixed-sized audio chunks (usually the case). + + Returns False when voice command is finished. + """ + if vad.samples_per_chunk is None: + # No chunking + chunk_seconds = (len(chunk) // _SAMPLE_WIDTH) / _SAMPLE_RATE + is_speech = vad.is_speech(chunk) + return self.process(chunk_seconds, is_speech) + + if leftover_chunk_buffer is None: + raise ValueError("leftover_chunk_buffer is required when vad uses chunking") + + # With chunking + seconds_per_chunk = vad.samples_per_chunk / _SAMPLE_RATE + bytes_per_chunk = vad.samples_per_chunk * _SAMPLE_WIDTH + for vad_chunk in chunk_samples(chunk, bytes_per_chunk, leftover_chunk_buffer): + is_speech = vad.is_speech(vad_chunk) + if not self.process(seconds_per_chunk, is_speech): + return False + + return True + @dataclass class VoiceActivityTimeout: @@ -198,73 +225,43 @@ class VoiceActivityTimeout: reset_seconds: float = 0.5 """Seconds of speech before resetting timeout.""" - vad_mode: int = 3 - """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - - vad_samples_per_chunk: int = 480 # 30 ms - """Must be 10, 20, or 30 ms at 16Khz.""" - _silence_seconds_left: float = 0.0 """Seconds left before considering voice command as stopped.""" _reset_seconds_left: float = 0.0 """Seconds left before resetting start/stop time counters.""" - _vad: webrtcvad.Vad = None - _leftover_chunk_buffer: AudioBuffer = field(init=False) - _bytes_per_chunk: int = field(init=False) - _seconds_per_chunk: float = field(init=False) - def __post_init__(self) -> None: - """Initialize VAD.""" - self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH - self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE - self._leftover_chunk_buffer = AudioBuffer( - self.vad_samples_per_chunk * _SAMPLE_WIDTH - ) + """Reset after initialization.""" self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._leftover_chunk_buffer.clear() self._silence_seconds_left = self.silence_seconds self._reset_seconds_left = self.reset_seconds - def process(self, samples: bytes) -> bool: - """Process 16-bit 16Khz mono audio samples. + def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + """Process samples using external VAD. Returns False when timeout is reached. """ - for chunk in chunk_samples( - samples, self._bytes_per_chunk, self._leftover_chunk_buffer - ): - if not self._process_chunk(chunk): - return False - - return True - - def _process_chunk(self, chunk: bytes) -> bool: - """Process a single chunk of 16-bit 16Khz mono audio. - - Returns False when timeout is reached. - """ - if self._vad.is_speech(chunk, _SAMPLE_RATE): + if is_speech: # Speech - self._reset_seconds_left -= self._seconds_per_chunk + self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: # Reset timeout self._silence_seconds_left = self.silence_seconds else: # Silence - self._silence_seconds_left -= self._seconds_per_chunk + self._silence_seconds_left -= chunk_seconds if self._silence_seconds_left <= 0: # Timeout reached + self.reset() return False # Slowly build reset counter back up self._reset_seconds_left = min( - self.reset_seconds, self._reset_seconds_left + self._seconds_per_chunk + self.reset_seconds, self._reset_seconds_left + chunk_seconds ) return True diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 6d8fd02a217..bc542b5c32b 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -18,6 +18,7 @@ from homeassistant.util import language as language_util from .const import DOMAIN from .error import PipelineNotFound from .pipeline import ( + AudioSettings, PipelineData, PipelineError, PipelineEvent, @@ -71,6 +72,13 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: vol.Optional("audio_seconds_to_buffer"): vol.Any( float, int ), + # Audio enhancement + vol.Optional("noise_suppression_level"): int, + vol.Optional("auto_gain_dbfs"): int, + vol.Optional("volume_multiplier"): float, + # Advanced use cases/testing + vol.Optional("no_vad"): bool, + vol.Optional("no_chunking"): bool, } }, extra=vol.ALLOW_EXTRA, @@ -115,6 +123,7 @@ async def websocket_run( handler_id: int | None = None unregister_handler: Callable[[], None] | None = None wake_word_settings: WakeWordSettings | None = None + audio_settings: AudioSettings | None = None # Arguments to PipelineInput input_args: dict[str, Any] = { @@ -124,13 +133,14 @@ async def websocket_run( if start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT): # Audio pipeline that will receive audio as binary websocket messages + msg_input = msg["input"] audio_queue: asyncio.Queue[bytes] = asyncio.Queue() - incoming_sample_rate = msg["input"]["sample_rate"] + incoming_sample_rate = msg_input["sample_rate"] if start_stage == PipelineStage.WAKE_WORD: wake_word_settings = WakeWordSettings( timeout=msg["input"].get("timeout", DEFAULT_WAKE_WORD_TIMEOUT), - audio_seconds_to_buffer=msg["input"].get("audio_seconds_to_buffer", 0), + audio_seconds_to_buffer=msg_input.get("audio_seconds_to_buffer", 0), ) async def stt_stream() -> AsyncGenerator[bytes, None]: @@ -166,6 +176,15 @@ async def websocket_run( channel=stt.AudioChannels.CHANNEL_MONO, ) input_args["stt_stream"] = stt_stream() + + # Audio settings + audio_settings = AudioSettings( + noise_suppression_level=msg_input.get("noise_suppression_level", 0), + auto_gain_dbfs=msg_input.get("auto_gain_dbfs", 0), + volume_multiplier=msg_input.get("volume_multiplier", 1.0), + is_vad_enabled=not msg_input.get("no_vad", False), + is_chunking_enabled=not msg_input.get("no_chunking", False), + ) elif start_stage == PipelineStage.INTENT: # Input to conversation agent input_args["intent_input"] = msg["input"]["text"] @@ -185,6 +204,7 @@ async def websocket_run( "timeout": timeout, }, wake_word_settings=wake_word_settings, + audio_settings=audio_settings or AudioSettings(), ) pipeline_input = PipelineInput(**input_args) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index efa62e0e8f4..6ea97268684 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -29,8 +29,11 @@ from homeassistant.components.assist_pipeline import ( select as pipeline_select, ) from homeassistant.components.assist_pipeline.vad import ( + AudioBuffer, VadSensitivity, + VoiceActivityDetector, VoiceCommandSegmenter, + WebRtcVad, ) from homeassistant.const import __version__ from homeassistant.core import Context, HomeAssistant @@ -225,11 +228,13 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): try: # Wait for speech before starting pipeline segmenter = VoiceCommandSegmenter(silence_seconds=self.silence_seconds) + vad = WebRtcVad() chunk_buffer: deque[bytes] = deque( maxlen=self.buffered_chunks_before_speech, ) speech_detected = await self._wait_for_speech( segmenter, + vad, chunk_buffer, ) if not speech_detected: @@ -243,6 +248,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): try: async for chunk in self._segment_audio( segmenter, + vad, chunk_buffer, ): yield chunk @@ -306,6 +312,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async def _wait_for_speech( self, segmenter: VoiceCommandSegmenter, + vad: VoiceActivityDetector, chunk_buffer: MutableSequence[bytes], ): """Buffer audio chunks until speech is detected. @@ -317,12 +324,18 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() + assert vad.samples_per_chunk is not None + vad_buffer = AudioBuffer(vad.samples_per_chunk * WIDTH) + while chunk: chunk_buffer.append(chunk) - segmenter.process(chunk) + segmenter.process_with_vad(chunk, vad, vad_buffer) if segmenter.in_command: # Buffer until command starts + if len(vad_buffer) > 0: + chunk_buffer.append(vad_buffer.bytes()) + return True async with asyncio.timeout(self.audio_timeout): @@ -333,6 +346,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async def _segment_audio( self, segmenter: VoiceCommandSegmenter, + vad: VoiceActivityDetector, chunk_buffer: Sequence[bytes], ) -> AsyncIterable[bytes]: """Yield audio chunks until voice command has finished.""" @@ -345,8 +359,11 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() + assert vad.samples_per_chunk is not None + vad_buffer = AudioBuffer(vad.samples_per_chunk * WIDTH) + while chunk: - if not segmenter.process(chunk): + if not segmenter.process_with_vad(chunk, vad, vad_buffer): # Voice command is finished break diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 893ac1bc26a..e6c019092f7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -51,7 +51,7 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 -webrtcvad==2.0.10 +webrtc-noise-gain==1.1.0 yarl==1.9.2 zeroconf==0.114.0 diff --git a/requirements_all.txt b/requirements_all.txt index 506cc9a5f96..738794f5a84 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2691,7 +2691,7 @@ waterfurnace==1.1.0 webexteamssdk==1.1.1 # homeassistant.components.assist_pipeline -webrtcvad==2.0.10 +webrtc-noise-gain==1.1.0 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62eade4c102..c93ff021492 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1994,7 +1994,7 @@ wallbox==0.4.12 watchdog==2.3.1 # homeassistant.components.assist_pipeline -webrtcvad==2.0.10 +webrtc-noise-gain==1.1.0 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f80f294c09d..f36a334d97d 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -311,18 +311,6 @@ }), 'type': , }), - dict({ - 'data': dict({ - 'timestamp': 0, - }), - 'type': , - }), - dict({ - 'data': dict({ - 'timestamp': 1500, - }), - 'type': , - }), dict({ 'data': dict({ 'stt_output': dict({ diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index e8eb573b374..dd88997262f 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -173,6 +173,87 @@ 'message': 'No wake-word-detection provider for: wake_word.bad-entity-id', }) # --- +# name: test_audio_pipeline_with_enhancements + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 30, + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.1 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.3 + dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en', + }) +# --- +# name: test_audio_pipeline_with_enhancements.4 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.5 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', + }) +# --- +# name: test_audio_pipeline_with_enhancements.6 + dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + }), + }) +# --- +# name: test_audio_pipeline_with_enhancements.7 + None +# --- # name: test_audio_pipeline_with_wake_word dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 1a7362aab80..b41e23d7a0d 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -64,6 +64,9 @@ async def test_pipeline_from_audio_stream_auto( channel=stt.AudioChannels.CHANNEL_MONO, ), stt_stream=audio_data(), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -126,6 +129,9 @@ async def test_pipeline_from_audio_stream_legacy( ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -188,6 +194,9 @@ async def test_pipeline_from_audio_stream_entity( ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert process_events(events) == snapshot @@ -251,6 +260,9 @@ async def test_pipeline_from_audio_stream_no_stt( ), stt_stream=audio_data(), pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) assert not events @@ -312,44 +324,47 @@ async def test_pipeline_from_audio_stream_wake_word( # [0, 2, ...] wake_chunk_2 = bytes(it.islice(it.cycle(range(0, 256, 2)), BYTES_ONE_SECOND)) + bytes_per_chunk = int(0.01 * BYTES_ONE_SECOND) + async def audio_data(): - yield wake_chunk_1 # 1 second - yield wake_chunk_2 # 1 second + # 1 second in 10 ms chunks + i = 0 + while i < len(wake_chunk_1): + yield wake_chunk_1[i : i + bytes_per_chunk] + i += bytes_per_chunk + + # 1 second in 30 ms chunks + i = 0 + while i < len(wake_chunk_2): + yield wake_chunk_2[i : i + bytes_per_chunk] + i += bytes_per_chunk + yield b"wake word!" yield b"part1" yield b"part2" - yield b"end" yield b"" - def continue_stt(self, chunk): - # Ensure stt_vad_start event is triggered - self.in_command = True - - # Stop on fake end chunk to trigger stt_vad_end - return chunk != b"end" - - with patch( - "homeassistant.components.assist_pipeline.pipeline.VoiceCommandSegmenter.process", - continue_stt, - ): - await assist_pipeline.async_pipeline_from_audio_stream( - hass, - context=Context(), - event_callback=events.append, - 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, - wake_word_settings=assist_pipeline.WakeWordSettings( - audio_seconds_to_buffer=1.5 - ), - ) + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + 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, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), + ) assert process_events(events) == snapshot @@ -357,12 +372,14 @@ async def test_pipeline_from_audio_stream_wake_word( # 2. queued audio (from mock wake word entity) # 3. part1 # 4. part2 - assert len(mock_stt_provider.received) == 4 + assert len(mock_stt_provider.received) > 3 - first_chunk = mock_stt_provider.received[0] + first_chunk = bytes( + [c_byte for c in mock_stt_provider.received[:-3] for c_byte in c] + ) assert first_chunk == wake_chunk_1[len(wake_chunk_1) // 2 :] + wake_chunk_2 - assert mock_stt_provider.received[1:] == [b"queued audio", b"part1", b"part2"] + assert mock_stt_provider.received[-3:] == [b"queued audio", b"part1", b"part2"] async def test_pipeline_save_audio( @@ -410,6 +427,9 @@ async def test_pipeline_save_audio( pipeline_id=pipeline.id, start_stage=assist_pipeline.PipelineStage.WAKE_WORD, end_stage=assist_pipeline.PipelineStage.STT, + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), ) pipeline_dirs = list(temp_dir.iterdir()) diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py index 4dc8c8f6197..57b567c49df 100644 --- a/tests/components/assist_pipeline/test_vad.py +++ b/tests/components/assist_pipeline/test_vad.py @@ -1,14 +1,15 @@ -"""Tests for webrtcvad voice command segmenter.""" +"""Tests for voice command segmenter.""" import itertools as it from unittest.mock import patch from homeassistant.components.assist_pipeline.vad import ( AudioBuffer, + VoiceActivityDetector, VoiceCommandSegmenter, chunk_samples, ) -_ONE_SECOND = 16000 * 2 # 16Khz 16-bit +_ONE_SECOND = 1.0 def test_silence() -> None: @@ -16,87 +17,85 @@ def test_silence() -> None: segmenter = VoiceCommandSegmenter() # True return value indicates voice command has not finished - assert segmenter.process(bytes(_ONE_SECOND * 3)) + assert segmenter.process(_ONE_SECOND * 3, False) def test_speech() -> None: """Test that silence + speech + silence triggers a voice command.""" - def is_speech(self, chunk, sample_rate): + def is_speech(chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 - with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ): - segmenter = VoiceCommandSegmenter() + segmenter = VoiceCommandSegmenter() - # silence - assert segmenter.process(bytes(_ONE_SECOND)) + # silence + assert segmenter.process(_ONE_SECOND, False) - # "speech" - assert segmenter.process(bytes([255] * _ONE_SECOND)) + # "speech" + assert segmenter.process(_ONE_SECOND, True) - # silence - # False return value indicates voice command is finished - assert not segmenter.process(bytes(_ONE_SECOND)) + # silence + # False return value indicates voice command is finished + assert not segmenter.process(_ONE_SECOND, False) def test_audio_buffer() -> None: """Test audio buffer wrapping.""" - def is_speech(self, chunk, sample_rate): - """Disable VAD.""" - return False + class DisabledVad(VoiceActivityDetector): + def is_speech(self, chunk): + return False - with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ): - segmenter = VoiceCommandSegmenter() - bytes_per_chunk = segmenter.vad_samples_per_chunk * 2 + @property + def samples_per_chunk(self): + return 160 # 10 ms - with patch.object( - segmenter, "_process_chunk", return_value=True - ) as mock_process: - # Partially fill audio buffer - half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2)) - segmenter.process(half_chunk) + vad = DisabledVad() + bytes_per_chunk = vad.samples_per_chunk * 2 + vad_buffer = AudioBuffer(bytes_per_chunk) + segmenter = VoiceCommandSegmenter() - assert not mock_process.called - assert segmenter.audio_buffer == half_chunk + with patch.object(vad, "is_speech", return_value=False) as mock_process: + # Partially fill audio buffer + half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2)) + segmenter.process_with_vad(half_chunk, vad, vad_buffer) - # Fill and wrap with 1/4 chunk left over - three_quarters_chunk = bytes( - it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk)) - ) - segmenter.process(three_quarters_chunk) + assert not mock_process.called + assert vad_buffer is not None + assert vad_buffer.bytes() == half_chunk - assert mock_process.call_count == 1 - assert ( - segmenter.audio_buffer - == three_quarters_chunk[ - len(three_quarters_chunk) - (bytes_per_chunk // 4) : - ] - ) - assert ( - mock_process.call_args[0][0] - == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2] - ) + # Fill and wrap with 1/4 chunk left over + three_quarters_chunk = bytes( + it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk)) + ) + segmenter.process_with_vad(three_quarters_chunk, vad, vad_buffer) - # Run 2 chunks through - segmenter.reset() - assert len(segmenter.audio_buffer) == 0 + assert mock_process.call_count == 1 + assert ( + vad_buffer.bytes() + == three_quarters_chunk[ + len(three_quarters_chunk) - (bytes_per_chunk // 4) : + ] + ) + assert ( + mock_process.call_args[0][0] + == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2] + ) - mock_process.reset_mock() - two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2)) - segmenter.process(two_chunks) + # Run 2 chunks through + segmenter.reset() + vad_buffer.clear() + assert len(vad_buffer) == 0 - assert mock_process.call_count == 2 - assert len(segmenter.audio_buffer) == 0 - assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk] - assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:] + mock_process.reset_mock() + two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2)) + segmenter.process_with_vad(two_chunks, vad, vad_buffer) + + assert mock_process.call_count == 2 + assert len(vad_buffer) == 0 + assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk] + assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:] def test_partial_chunk() -> None: @@ -125,3 +124,43 @@ def test_chunk_samples_leftover() -> None: assert len(chunks) == 1 assert leftover_chunk_buffer.bytes() == bytes([5, 6]) + + +def test_vad_no_chunking() -> None: + """Test VAD that doesn't require chunking.""" + + class VadNoChunk(VoiceActivityDetector): + def is_speech(self, chunk: bytes) -> bool: + return sum(chunk) > 0 + + @property + def samples_per_chunk(self) -> int | None: + return None + + vad = VadNoChunk() + segmenter = VoiceCommandSegmenter( + speech_seconds=1.0, silence_seconds=1.0, reset_seconds=0.5 + ) + silence = bytes([0] * 16000) + speech = bytes([255] * (16000 // 2)) + + # Test with differently-sized chunks + assert vad.is_speech(speech) + assert not vad.is_speech(silence) + + # Simulate voice command + assert segmenter.process_with_vad(silence, vad, None) + # begin + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + # reset with silence + assert segmenter.process_with_vad(silence, vad, None) + # resume + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + assert segmenter.process_with_vad(speech, vad, None) + # end + assert segmenter.process_with_vad(silence, vad, None) + assert not segmenter.process_with_vad(silence, vad, None) diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index e3561e77852..76ec88b009b 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -107,6 +107,7 @@ async def test_audio_pipeline( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -116,7 +117,7 @@ async def test_audio_pipeline( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -240,6 +241,8 @@ async def test_audio_pipeline_with_wake_word_no_timeout( "input": { "sample_rate": 16000, "timeout": 0, + "no_vad": True, + "no_chunking": True, }, } ) @@ -253,6 +256,7 @@ async def test_audio_pipeline_with_wake_word_no_timeout( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # wake_word @@ -276,7 +280,7 @@ async def test_audio_pipeline_with_wake_word_no_timeout( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -731,6 +735,7 @@ async def test_stt_stream_failed( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -740,7 +745,7 @@ async def test_stt_stream_failed( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") + await client.send_bytes(bytes([handler_id])) # stt error msg = await client.receive_json() @@ -1489,6 +1494,7 @@ async def test_audio_pipeline_debug( assert msg["event"]["type"] == "run-start" msg["event"]["data"]["pipeline"] = ANY assert msg["event"]["data"] == snapshot + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] events.append(msg["event"]) # stt @@ -1498,7 +1504,7 @@ async def test_audio_pipeline_debug( events.append(msg["event"]) # End of audio stream (handler id + empty payload) - await client.send_bytes(bytes([1])) + await client.send_bytes(bytes([handler_id])) msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" @@ -1699,3 +1705,103 @@ async def test_list_pipeline_languages_with_aliases( msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"languages": ["he", "nb"]} + + +async def test_audio_pipeline_with_enhancements( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with audio input/output.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + # Enhancements + "noise_suppression_level": 2, + "auto_gain_dbfs": 15, + "volume_multiplier": 2.0, + }, + } + ) + + # 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 + handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + events.append(msg["event"]) + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # One second of silence. + # This will pass through the audio enhancement pipeline, but we don't test + # the actual output. + await client.send_bytes(bytes([handler_id]) + bytes(16000 * 2)) + + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([handler_id])) + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + 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 + 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"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 361e4e7f0e2..f82a00087c6 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -21,7 +21,7 @@ async def test_pipeline( """Test that pipeline function is called from RTP protocol.""" assert await async_setup_component(hass, "voip", {}) - def is_speech(self, chunk, sample_rate): + def is_speech(self, chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 @@ -76,7 +76,7 @@ async def test_pipeline( return ("mp3", b"") with patch( - "webrtcvad.Vad.is_speech", + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", new=is_speech, ), patch( "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", @@ -210,7 +210,7 @@ async def test_tts_timeout( """Test that TTS will time out based on its length.""" assert await async_setup_component(hass, "voip", {}) - def is_speech(self, chunk, sample_rate): + def is_speech(self, chunk): """Anything non-zero is speech.""" return sum(chunk) > 0 @@ -269,7 +269,7 @@ async def test_tts_timeout( return ("raw", bytes(0)) with patch( - "webrtcvad.Vad.is_speech", + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", new=is_speech, ), patch( "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", From 18f29993c5efbd84dec7a0951d532635f58ab7b2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 25 Sep 2023 17:08:59 -0700 Subject: [PATCH 796/984] Simplify fitbit unit system and conversions (#100825) * Simplify fitbit unit conversions * Use enum values in unit system schema * Use fitbit unit system enums --- homeassistant/components/fitbit/api.py | 48 ++++++++++-- homeassistant/components/fitbit/const.py | 69 ++++++----------- homeassistant/components/fitbit/sensor.py | 93 ++++++++++++----------- tests/components/fitbit/conftest.py | 9 +++ tests/components/fitbit/test_sensor.py | 24 +++++- 5 files changed, 143 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 19f6965a4bb..1b58d26e286 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -6,7 +6,9 @@ from typing import Any from fitbit import Fitbit from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import METRIC_SYSTEM +from .const import FitbitUnitSystem from .model import FitbitDevice, FitbitProfile _LOGGER = logging.getLogger(__name__) @@ -19,11 +21,13 @@ class FitbitApi: self, hass: HomeAssistant, client: Fitbit, + unit_system: FitbitUnitSystem | None = None, ) -> None: """Initialize Fitbit auth.""" self._hass = hass self._profile: FitbitProfile | None = None self._client = client + self._unit_system = unit_system @property def client(self) -> Fitbit: @@ -32,14 +36,38 @@ class FitbitApi: def get_user_profile(self) -> FitbitProfile: """Return the user profile from the API.""" - response: dict[str, Any] = self._client.user_profile_get() - _LOGGER.debug("user_profile_get=%s", response) - profile = response["user"] - return FitbitProfile( - encoded_id=profile["encodedId"], - full_name=profile["fullName"], - locale=profile.get("locale"), - ) + if self._profile is None: + response: dict[str, Any] = self._client.user_profile_get() + _LOGGER.debug("user_profile_get=%s", response) + profile = response["user"] + self._profile = FitbitProfile( + encoded_id=profile["encodedId"], + full_name=profile["fullName"], + locale=profile.get("locale"), + ) + return self._profile + + def get_unit_system(self) -> FitbitUnitSystem: + """Get the unit system to use when fetching timeseries. + + This is used in a couple ways. The first is to determine the request + header to use when talking to the fitbit API which changes the + units returned by the API. The second is to tell Home Assistant the + units set in sensor values for the values returned by the API. + """ + if ( + self._unit_system is not None + and self._unit_system != FitbitUnitSystem.LEGACY_DEFAULT + ): + return self._unit_system + # Use units consistent with the account user profile or fallback to the + # home assistant unit settings. + profile = self.get_user_profile() + if profile.locale == FitbitUnitSystem.EN_GB: + return FitbitUnitSystem.EN_GB + if self._hass.config.units is METRIC_SYSTEM: + return FitbitUnitSystem.METRIC + return FitbitUnitSystem.EN_US def get_devices(self) -> list[FitbitDevice]: """Return available devices.""" @@ -58,6 +86,10 @@ class FitbitApi: def get_latest_time_series(self, resource_type: str) -> dict[str, Any]: """Return the most recent value from the time series for the specified resource type.""" + + # Set request header based on the configured unit system + self._client.system = self.get_unit_system() + response: dict[str, Any] = self._client.time_series(resource_type, period="7d") _LOGGER.debug("time_series(%s)=%s", resource_type, response) key = resource_type.replace("/", "-") diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 045b58cfc5e..19734add07a 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -1,16 +1,10 @@ """Constants for the Fitbit platform.""" from __future__ import annotations +from enum import StrEnum from typing import Final -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - UnitOfLength, - UnitOfMass, - UnitOfTime, - UnitOfVolume, -) +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET DOMAIN: Final = "fitbit" @@ -43,46 +37,31 @@ DEFAULT_CONFIG: Final[dict[str, str]] = { } DEFAULT_CLOCK_FORMAT: Final = "24H" - -FITBIT_MEASUREMENTS: Final[dict[str, dict[str, str]]] = { - "en_US": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.MILES, - ATTR_ELEVATION: UnitOfLength.FEET, - ATTR_HEIGHT: UnitOfLength.INCHES, - ATTR_WEIGHT: UnitOfMass.POUNDS, - ATTR_BODY: UnitOfLength.INCHES, - ATTR_LIQUIDS: UnitOfVolume.FLUID_OUNCES, - ATTR_BLOOD_GLUCOSE: f"{UnitOfMass.MILLIGRAMS}/dL", - ATTR_BATTERY: "", - }, - "en_GB": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.KILOMETERS, - ATTR_ELEVATION: UnitOfLength.METERS, - ATTR_HEIGHT: UnitOfLength.CENTIMETERS, - ATTR_WEIGHT: UnitOfMass.STONES, - ATTR_BODY: UnitOfLength.CENTIMETERS, - ATTR_LIQUIDS: UnitOfVolume.MILLILITERS, - ATTR_BLOOD_GLUCOSE: "mmol/L", - ATTR_BATTERY: "", - }, - "metric": { - ATTR_DURATION: UnitOfTime.MILLISECONDS, - ATTR_DISTANCE: UnitOfLength.KILOMETERS, - ATTR_ELEVATION: UnitOfLength.METERS, - ATTR_HEIGHT: UnitOfLength.CENTIMETERS, - ATTR_WEIGHT: UnitOfMass.KILOGRAMS, - ATTR_BODY: UnitOfLength.CENTIMETERS, - ATTR_LIQUIDS: UnitOfVolume.MILLILITERS, - ATTR_BLOOD_GLUCOSE: "mmol/L", - ATTR_BATTERY: "", - }, -} - BATTERY_LEVELS: Final[dict[str, int]] = { "High": 100, "Medium": 50, "Low": 20, "Empty": 0, } + + +class FitbitUnitSystem(StrEnum): + """Fitbit unit system set when sending requests to the Fitbit API. + + This is used as a header to tell the Fitbit API which type of units to return. + https://dev.fitbit.com/build/reference/web-api/developer-guide/application-design/#Units + + Prefer to leave unset for newer configurations to use the Home Assistant default units. + """ + + LEGACY_DEFAULT = "default" + """When set, will use an appropriate default using a legacy algorithm.""" + + METRIC = "metric" + """Use metric units.""" + + EN_US = "en_US" + """Use United States units.""" + + EN_GB = "en_GB" + """Use United Kingdom units.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 3b1c831b116..653a4ee2508 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -29,6 +29,8 @@ from homeassistant.const import ( CONF_CLIENT_SECRET, CONF_UNIT_SYSTEM, PERCENTAGE, + UnitOfLength, + UnitOfMass, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -39,7 +41,6 @@ from homeassistant.helpers.json import save_json from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json_object -from homeassistant.util.unit_system import METRIC_SYSTEM from .api import FitbitApi from .const import ( @@ -56,9 +57,9 @@ from .const import ( FITBIT_AUTH_START, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, - FITBIT_MEASUREMENTS, + FitbitUnitSystem, ) -from .model import FitbitDevice, FitbitProfile +from .model import FitbitDevice _LOGGER: Final = logging.getLogger(__name__) @@ -97,12 +98,36 @@ def _clock_format_12h(result: dict[str, Any]) -> str: return f"{hours}:{minutes:02d} {setting}" +def _weight_unit(unit_system: FitbitUnitSystem) -> UnitOfMass: + """Determine the weight unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfMass.POUNDS + if unit_system == FitbitUnitSystem.EN_GB: + return UnitOfMass.STONES + return UnitOfMass.KILOGRAMS + + +def _distance_unit(unit_system: FitbitUnitSystem) -> UnitOfLength: + """Determine the distance unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfLength.MILES + return UnitOfLength.KILOMETERS + + +def _elevation_unit(unit_system: FitbitUnitSystem) -> UnitOfLength: + """Determine the elevation unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfLength.FEET + return UnitOfLength.METERS + + @dataclass class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" unit_type: str | None = None value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn + unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -127,17 +152,17 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( FitbitSensorEntityDescription( key="activities/distance", name="Distance", - unit_type="distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, + unit_fn=_distance_unit, ), FitbitSensorEntityDescription( key="activities/elevation", name="Elevation", - unit_type="elevation", icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, + unit_fn=_elevation_unit, ), FitbitSensorEntityDescription( key="activities/floors", @@ -201,17 +226,17 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( FitbitSensorEntityDescription( key="activities/tracker/distance", name="Tracker Distance", - unit_type="distance", icon="mdi:map-marker", device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, + unit_fn=_distance_unit, ), FitbitSensorEntityDescription( key="activities/tracker/elevation", name="Tracker Elevation", - unit_type="elevation", icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, + unit_fn=_elevation_unit, ), FitbitSensorEntityDescription( key="activities/tracker/floors", @@ -272,11 +297,11 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( FitbitSensorEntityDescription( key="body/weight", name="Weight", - unit_type="weight", icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WEIGHT, value_fn=_body_value_fn, + unit_fn=_weight_unit, ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", @@ -360,8 +385,13 @@ PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( vol.Optional(CONF_CLOCK_FORMAT, default=DEFAULT_CLOCK_FORMAT): vol.In( ["12H", "24H"] ), - vol.Optional(CONF_UNIT_SYSTEM, default="default"): vol.In( - ["en_GB", "en_US", "metric", "default"] + vol.Optional(CONF_UNIT_SYSTEM, default=FitbitUnitSystem.LEGACY_DEFAULT): vol.In( + [ + FitbitUnitSystem.EN_GB, + FitbitUnitSystem.EN_US, + FitbitUnitSystem.METRIC, + FitbitUnitSystem.LEGACY_DEFAULT, + ] ), } ) @@ -487,17 +517,9 @@ def setup_platform( if int(time.time()) - cast(int, expires_at) > 3600: authd_client.client.refresh_token() - api = FitbitApi(hass, authd_client) + api = FitbitApi(hass, authd_client, config[CONF_UNIT_SYSTEM]) user_profile = api.get_user_profile() - if (unit_system := config[CONF_UNIT_SYSTEM]) == "default": - authd_client.system = user_profile.locale - if authd_client.system != "en_GB": - if hass.config.units is METRIC_SYSTEM: - authd_client.system = "metric" - else: - authd_client.system = "en_US" - else: - authd_client.system = unit_system + unit_system = api.get_unit_system() clock_format = config[CONF_CLOCK_FORMAT] monitored_resources = config[CONF_MONITORED_RESOURCES] @@ -508,11 +530,10 @@ def setup_platform( entities = [ FitbitSensor( api, - user_profile, + user_profile.encoded_id, config_path, description, - hass.config.units is METRIC_SYSTEM, - clock_format, + units=description.unit_fn(unit_system), ) for description in resource_list if description.key in monitored_resources @@ -523,11 +544,9 @@ def setup_platform( [ FitbitSensor( api, - user_profile, + user_profile.encoded_id, config_path, FITBIT_RESOURCE_BATTERY, - hass.config.units is METRIC_SYSTEM, - clock_format, device, ) for device in devices @@ -646,37 +665,25 @@ class FitbitSensor(SensorEntity): def __init__( self, api: FitbitApi, - user_profile: FitbitProfile, + user_profile_id: str, config_path: str, description: FitbitSensorEntityDescription, - is_metric: bool, - clock_format: str, device: FitbitDevice | None = None, + units: str | None = None, ) -> None: """Initialize the Fitbit sensor.""" self.entity_description = description self.api = api self.config_path = config_path - self.is_metric = is_metric - self.clock_format = clock_format self.device = device - self._attr_unique_id = f"{user_profile.encoded_id}_{description.key}" + self._attr_unique_id = f"{user_profile_id}_{description.key}" if device is not None: self._attr_name = f"{device.device_version} Battery" self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" - if description.unit_type: - try: - measurement_system = FITBIT_MEASUREMENTS[self.api.client.system] - except KeyError: - if self.is_metric: - measurement_system = FITBIT_MEASUREMENTS["metric"] - else: - measurement_system = FITBIT_MEASUREMENTS["en_US"] - split_resource = description.key.rsplit("/", maxsplit=1)[-1] - unit_type = measurement_system[split_resource] - self._attr_native_unit_of_measurement = unit_type + if units is not None: + self._attr_native_unit_of_measurement = units @property def icon(self) -> str | None: diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 291951a745a..7499a060933 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -66,14 +66,23 @@ def mock_monitored_resources() -> list[str] | None: return None +@pytest.fixture(name="configured_unit_system") +def mock_configured_unit_syststem() -> str | None: + """Fixture for the fitbit yaml config monitored_resources field.""" + return None + + @pytest.fixture(name="sensor_platform_config") def mock_sensor_platform_config( monitored_resources: list[str] | None, + configured_unit_system: str | None, ) -> dict[str, Any]: """Fixture for the fitbit sensor platform configuration data in configuration.yaml.""" config = {} if monitored_resources is not None: config["monitored_resources"] = monitored_resources + if configured_unit_system is not None: + config["unit_system"] = configured_unit_system return config diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 7351f919380..636afeacf16 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -244,11 +244,27 @@ async def test_device_battery_level( @pytest.mark.parametrize( - ("monitored_resources", "profile_locale", "expected_unit"), + ( + "monitored_resources", + "profile_locale", + "configured_unit_system", + "expected_unit", + ), [ - (["body/weight"], "en_US", "kg"), - (["body/weight"], "en_GB", "st"), - (["body/weight"], "es_ES", "kg"), + # Defaults to home assistant unit system unless UK + (["body/weight"], "en_US", "default", "kg"), + (["body/weight"], "en_GB", "default", "st"), + (["body/weight"], "es_ES", "default", "kg"), + # Use the configured unit system from yaml + (["body/weight"], "en_US", "en_US", "lb"), + (["body/weight"], "en_GB", "en_US", "lb"), + (["body/weight"], "es_ES", "en_US", "lb"), + (["body/weight"], "en_US", "en_GB", "st"), + (["body/weight"], "en_GB", "en_GB", "st"), + (["body/weight"], "es_ES", "en_GB", "st"), + (["body/weight"], "en_US", "metric", "kg"), + (["body/weight"], "en_GB", "metric", "kg"), + (["body/weight"], "es_ES", "metric", "kg"), ], ) async def test_profile_local( From fa2d77407a7e6c1806a4798f215d436f2ed0aad5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 25 Sep 2023 17:27:38 -0700 Subject: [PATCH 797/984] Add Rain Bird irrigation calendar (#87604) * Initial version of a calendar for the rainbird integration * Improve calendar support * Revert changes to test fixtures * Address ruff error * Fix background task scheduling * Use pytest.mark.freezetime to move to test setup * Address PR feedback * Make refresh a member * Merge rainbird and calendar changes * Increase test coverage * Readability improvements * Simplify timezone handling --- homeassistant/components/rainbird/__init__.py | 24 +- .../components/rainbird/binary_sensor.py | 2 +- homeassistant/components/rainbird/calendar.py | 118 ++++++++ .../components/rainbird/coordinator.py | 75 ++++- homeassistant/components/rainbird/number.py | 2 +- homeassistant/components/rainbird/sensor.py | 2 +- homeassistant/components/rainbird/switch.py | 2 +- tests/components/rainbird/conftest.py | 11 +- tests/components/rainbird/test_calendar.py | 272 ++++++++++++++++++ tests/components/rainbird/test_number.py | 2 +- tests/components/rainbird/test_switch.py | 1 - 11 files changed, 488 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/rainbird/calendar.py create mode 100644 tests/components/rainbird/test_calendar.py diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 2af0cb30f1e..a97af14f449 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -10,10 +10,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_SERIAL_NUMBER -from .coordinator import RainbirdUpdateCoordinator +from .coordinator import RainbirdData -PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER] +PLATFORMS = [ + Platform.SWITCH, + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.CALENDAR, +] DOMAIN = "rainbird" @@ -35,16 +40,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model_info = await controller.get_model_and_version() except RainbirdApiException as err: raise ConfigEntryNotReady from err - coordinator = RainbirdUpdateCoordinator( - hass, - name=entry.title, - controller=controller, - serial_number=entry.data[CONF_SERIAL_NUMBER], - model_info=model_info, - ) - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + data = RainbirdData(hass, entry, controller, model_info) + await data.coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 139a17f5181..b5886011ea3 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)]) diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py new file mode 100644 index 00000000000..4d8cc38c8bf --- /dev/null +++ b/homeassistant/components/rainbird/calendar.py @@ -0,0 +1,118 @@ +"""Rain Bird irrigation calendar.""" + +from __future__ import annotations + +from datetime import datetime +import logging + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +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 homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import RainbirdScheduleUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry for a Rain Bird irrigation calendar.""" + data = hass.data[DOMAIN][config_entry.entry_id] + if not data.model_info.model_info.max_programs: + return + + async_add_entities( + [ + RainBirdCalendarEntity( + data.schedule_coordinator, + data.coordinator.serial_number, + data.coordinator.device_info, + ) + ] + ) + + +class RainBirdCalendarEntity( + CoordinatorEntity[RainbirdScheduleUpdateCoordinator], CalendarEntity +): + """A calendar event entity.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_icon = "mdi:sprinkler" + + def __init__( + self, + coordinator: RainbirdScheduleUpdateCoordinator, + serial_number: str, + device_info: DeviceInfo, + ) -> None: + """Create the Calendar event device.""" + super().__init__(coordinator) + self._event: CalendarEvent | None = None + self._attr_unique_id = serial_number + self._attr_device_info = device_info + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + schedule = self.coordinator.data + if not schedule: + return None + cursor = schedule.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( + dt_util.now() + ) + program_event = next(cursor, None) + if not program_event: + return None + return CalendarEvent( + summary=program_event.program_id.name, + start=dt_util.as_local(program_event.start), + end=dt_util.as_local(program_event.end), + rrule=program_event.rrule_str, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + schedule = self.coordinator.data + if not schedule: + raise HomeAssistantError( + "Unable to get events: No data from controller yet" + ) + cursor = schedule.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [ + CalendarEvent( + summary=program_event.program_id.name, + start=dt_util.as_local(program_event.start), + end=dt_util.as_local(program_event.end), + rrule=program_event.rrule_str, + ) + for program_event in cursor + ] + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We do not ask for an update with async_add_entities() + # because it will update disabled entities. This is started as a + # task to let it sync in the background without blocking startup + self.coordinator.config_entry.async_create_background_task( + self.hass, + self.coordinator.async_request_refresh(), + "rainbird.calendar-refresh", + ) diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index cac86d8c928..d61f9140771 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -5,23 +5,29 @@ from __future__ import annotations import asyncio from dataclasses import dataclass import datetime +from functools import cached_property import logging from typing import TypeVar +import async_timeout from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, RainbirdDeviceBusyException, ) -from pyrainbird.data import ModelAndVersion +from pyrainbird.data import ModelAndVersion, Schedule +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) +# The calendar data requires RPCs for each program/zone, and the data rarely +# changes, so we refresh it less often. +CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15) _LOGGER = logging.getLogger(__name__) @@ -49,7 +55,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): serial_number: str, model_info: ModelAndVersion, ) -> None: - """Initialize ZoneStateUpdateCoordinator.""" + """Initialize RainbirdUpdateCoordinator.""" super().__init__( hass, _LOGGER, @@ -108,3 +114,66 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): rain=rain, rain_delay=rain_delay, ) + + +class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): + """Coordinator for rainbird irrigation schedule calls.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + name: str, + controller: AsyncRainbirdController, + ) -> None: + """Initialize ZoneStateUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=name, + update_method=self._async_update_data, + update_interval=CALENDAR_UPDATE_INTERVAL, + ) + self._controller = controller + + async def _async_update_data(self) -> Schedule: + """Fetch data from Rain Bird device.""" + try: + async with async_timeout.timeout(TIMEOUT_SECONDS): + return await self._controller.get_schedule() + except RainbirdApiException as err: + raise UpdateFailed(f"Error communicating with Device: {err}") from err + + +@dataclass +class RainbirdData: + """Holder for shared integration data. + + The coordinators are lazy since they may only be used by some platforms when needed. + """ + + hass: HomeAssistant + entry: ConfigEntry + controller: AsyncRainbirdController + model_info: ModelAndVersion + + @cached_property + def coordinator(self) -> RainbirdUpdateCoordinator: + """Return RainbirdUpdateCoordinator.""" + return RainbirdUpdateCoordinator( + self.hass, + name=self.entry.title, + controller=self.controller, + serial_number=self.entry.data[CONF_SERIAL_NUMBER], + model_info=self.model_info, + ) + + @cached_property + def schedule_coordinator(self) -> RainbirdScheduleUpdateCoordinator: + """Return RainbirdScheduleUpdateCoordinator.""" + return RainbirdScheduleUpdateCoordinator( + self.hass, + name=f"{self.entry.title} Schedule", + controller=self.controller, + ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index de049f921dd..d0945609a1b 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -28,7 +28,7 @@ async def async_setup_entry( async_add_entities( [ RainDelayNumber( - hass.data[DOMAIN][config_entry.entry_id], + hass.data[DOMAIN][config_entry.entry_id].coordinator, ) ] ) diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index f5cf2390095..32eb053f478 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry( async_add_entities( [ RainBirdSensor( - hass.data[DOMAIN][config_entry.entry_id], + hass.data[DOMAIN][config_entry.entry_id].coordinator, RAIN_DELAY_ENTITY_DESCRIPTION, ) ] diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 39bb4a7b0d1..cafc541d860 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator async_add_entities( RainBirdSwitch( coordinator, diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 40b400210aa..dbc3456117c 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -37,7 +37,7 @@ SERIAL_NUMBER = 0x12635436566 SERIAL_RESPONSE = "850000012635436566" ZERO_SERIAL_RESPONSE = "850000000000000000" # Model and version command 0x82 -MODEL_AND_VERSION_RESPONSE = "820006090C" +MODEL_AND_VERSION_RESPONSE = "820005090C" # ESP-TM2 # Get available stations command 0x83 AVAILABLE_STATIONS_RESPONSE = "83017F000000" # Mask for 7 zones EMPTY_STATIONS_RESPONSE = "830000000000" @@ -184,8 +184,15 @@ def mock_rain_delay_response() -> str: return RAIN_DELAY_OFF +@pytest.fixture(name="model_and_version_response") +def mock_model_and_version_response() -> str: + """Mock response to return rain delay state.""" + return MODEL_AND_VERSION_RESPONSE + + @pytest.fixture(name="api_responses") def mock_api_responses( + model_and_version_response: str, stations_response: str, zone_state_response: str, rain_response: str, @@ -196,7 +203,7 @@ def mock_api_responses( These are returned in the order they are requested by the update coordinator. """ return [ - MODEL_AND_VERSION_RESPONSE, + model_and_version_response, stations_response, zone_state_response, rain_response, diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py new file mode 100644 index 00000000000..2028fccc24f --- /dev/null +++ b/tests/components/rainbird/test_calendar.py @@ -0,0 +1,272 @@ +"""Tests for rainbird calendar platform.""" + + +from collections.abc import Awaitable, Callable +import datetime +from http import HTTPStatus +from typing import Any +import urllib +from zoneinfo import ZoneInfo + +from aiohttp import ClientSession +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup, mock_response, mock_response_error + +from tests.test_util.aiohttp import AiohttpClientMockResponse + +TEST_ENTITY = "calendar.rain_bird_controller" +GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] + +SCHEDULE_RESPONSES = [ + # Current controller status + "A0000000000000", + # Per-program information + "A00010060602006400", # CUSTOM: Monday & Tuesday + "A00011110602006400", + "A00012000300006400", + # Start times per program + "A0006000F0FFFFFFFFFFFF", # 4am + "A00061FFFFFFFFFFFFFFFF", + "A00062FFFFFFFFFFFFFFFF", + # Run times for each zone + "A00080001900000000001400000000", # zone1=25, zone2=20 + "A00081000700000000001400000000", # zone3=7, zone4=20 + "A00082000A00000000000000000000", # zone5=10 + "A00083000000000000000000000000", + "A00084000000000000000000000000", + "A00085000000000000000000000000", + "A00086000000000000000000000000", + "A00087000000000000000000000000", + "A00088000000000000000000000000", + "A00089000000000000000000000000", + "A0008A000000000000000000000000", +] + +EMPTY_SCHEDULE_RESPONSES = [ + # Current controller status + "A0000000000000", + # Per-program information (ignored) + "A00010000000000000", + "A00011000000000000", + "A00012000000000000", + # Start times for each program (off) + "A00060FFFFFFFFFFFFFFFF", + "A00061FFFFFFFFFFFFFFFF", + "A00062FFFFFFFFFFFFFFFF", + # Run times for each zone + "A00080000000000000000000000000", + "A00081000000000000000000000000", + "A00082000000000000000000000000", + "A00083000000000000000000000000", + "A00084000000000000000000000000", + "A00085000000000000000000000000", + "A00086000000000000000000000000", + "A00087000000000000000000000000", + "A00088000000000000000000000000", + "A00089000000000000000000000000", + "A0008A000000000000000000000000", +] + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CALENDAR] + + +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant): + """Set the time zone for the tests.""" + hass.config.set_time_zone("America/Regina") + + +@pytest.fixture(autouse=True) +def mock_schedule_responses() -> list[str]: + """Fixture containing fake irrigation schedule.""" + return SCHEDULE_RESPONSES + + +@pytest.fixture(autouse=True) +def mock_insert_schedule_response( + mock_schedule_responses: list[str], responses: list[AiohttpClientMockResponse] +) -> None: + """Fixture to insert device responses for the irrigation schedule.""" + responses.extend( + [mock_response(api_response) for api_response in mock_schedule_responses] + ) + + +@pytest.fixture(name="get_events") +def get_events_fixture( + hass_client: Callable[..., Awaitable[ClientSession]] +) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + results = await response.json() + return [{k: event[k] for k in {"summary", "start", "end"}} for event in results] + + return _fetch + + +@pytest.mark.freeze_time("2023-01-21 09:32:00") +async def test_get_events( + hass: HomeAssistant, setup_integration: ComponentSetup, 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 + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-23T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-23T05:22:00-06:00"}, + }, + # Tuesday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-24T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-24T05:22:00-06:00"}, + }, + # Monday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-30T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-30T05:22:00-06:00"}, + }, + # Tuesday + { + "summary": "PGM A", + "start": {"dateTime": "2023-01-31T04:00:00-06:00"}, + "end": {"dateTime": "2023-01-31T05:22:00-06:00"}, + }, + ] + + +@pytest.mark.parametrize( + ("freeze_time", "expected_state"), + [ + ( + datetime.datetime(2023, 1, 23, 3, 50, tzinfo=ZoneInfo("America/Regina")), + "off", + ), + ( + datetime.datetime(2023, 1, 23, 4, 30, tzinfo=ZoneInfo("America/Regina")), + "on", + ), + ], +) +async def test_event_state( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + freezer: FrozenDateTimeFactory, + freeze_time: datetime.datetime, + expected_state: str, +) -> None: + """Test calendar upcoming event state.""" + freezer.move_to(freeze_time) + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.attributes == { + "message": "PGM A", + "start_time": "2023-01-23 04:00:00", + "end_time": "2023-01-23 05:22:00", + "all_day": False, + "description": "", + "location": "", + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("model_and_version_response", "has_entity"), + [ + ("820005090C", True), + ("820006090C", False), + ], + ids=("ESP-TM2", "ST8x-WiFi"), +) +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 + + +@pytest.mark.parametrize( + "mock_insert_schedule_response", [([None])] # Disable success responses +) +async def test_no_schedule( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + responses: list[AiohttpClientMockResponse], + hass_client: Callable[..., Awaitable[ClientSession]], +) -> None: + """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 == { + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } + + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start=2023-08-01&end=2023-08-02" + ) + assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.freeze_time("2023-01-21 09:32:00") +@pytest.mark.parametrize( + "mock_schedule_responses", + [(EMPTY_SCHEDULE_RESPONSES)], +) +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 == [] + + state = hass.states.get(TEST_ENTITY) + assert state.state == "off" + assert state.attributes == { + "friendly_name": "Rain Bird Controller", + "icon": "mdi:sprinkler", + } diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 2c837a75c66..6ce7d10c9f2 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -73,7 +73,7 @@ async def test_set_value( device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) assert device assert device.name == "Rain Bird Controller" - assert device.model == "ST8x-WiFi" + assert device.model == "ESP-TM2" assert device.sw_version == "9.12" aioclient_mock.mock_calls.clear() diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 9127a0b0c61..9ce5e799c92 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -57,7 +57,6 @@ async def test_no_zones( async def test_zones( hass: HomeAssistant, setup_integration: ComponentSetup, - responses: list[AiohttpClientMockResponse], ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" From c5c5d9ed0cc83eed988b104f8839433a6443d6c3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 25 Sep 2023 22:33:04 -0400 Subject: [PATCH 798/984] Allow fetching wake word entity info (#100893) --- .../components/wake_word/__init__.py | 34 ++++++++++++++++++- tests/components/wake_word/test_init.py | 27 +++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index eeed7b8029b..9ce9cca75ff 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -3,9 +3,13 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import AsyncIterable +import dataclasses import logging from typing import final +import voluptuous as vol + +from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant, callback @@ -49,7 +53,9 @@ def async_get_wake_word_detection_entity( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up STT.""" + """Set up wake word.""" + websocket_api.async_register_command(hass, websocket_entity_info) + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) component.register_shutdown() @@ -120,3 +126,29 @@ class WakeWordDetectionEntity(RestoreEntity): and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): self.__last_detected = state.state + + +@websocket_api.websocket_command( + { + "type": "wake_word/info", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +@callback +def websocket_entity_info( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get info about wake word entity.""" + component: EntityComponent[WakeWordDetectionEntity] = hass.data[DOMAIN] + entity = component.get_entity(msg["entity_id"]) + + if entity is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found" + ) + return + + connection.send_result( + msg["id"], + {"wake_words": [dataclasses.asdict(ww) for ww in entity.supported_wake_words]}, + ) diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index 7f3e8f011ee..1e03632d083 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -22,6 +22,7 @@ from tests.common import ( mock_platform, mock_restore_cache, ) +from tests.typing import WebSocketGenerator TEST_DOMAIN = "test" @@ -259,3 +260,29 @@ async def test_entity_attributes( ) -> None: """Test that the provider entity attributes match expectations.""" assert mock_provider_entity.entity_category == EntityCategory.DIAGNOSTIC + + +async def test_list_wake_words( + hass: HomeAssistant, + setup: MockProviderEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the list_wake_words websocket command works.""" + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "wake_word/info", + "entity_id": setup.entity_id, + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == { + "wake_words": [ + {"ww_id": "test_ww", "name": "Test Wake Word"}, + {"ww_id": "test_ww_2", "name": "Test Wake Word 2"}, + ] + } From 9c1944f830eb045b20d088151dd5eec3ca88bca2 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Sep 2023 08:13:59 +0200 Subject: [PATCH 799/984] Enable strict typing in london underground (#100563) * Enable strict typing in london underground * Change typing from Any * Remove redundant cast * Change from Mapping to dict --- .strict-typing | 1 + .../components/london_underground/coordinator.py | 12 ++++++++---- .../components/london_underground/sensor.py | 9 +++++---- mypy.ini | 10 ++++++++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.strict-typing b/.strict-typing index 97af46884c4..439831790b0 100644 --- a/.strict-typing +++ b/.strict-typing @@ -214,6 +214,7 @@ homeassistant.components.local_ip.* homeassistant.components.lock.* homeassistant.components.logbook.* homeassistant.components.logger.* +homeassistant.components.london_underground.* homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.mailbox.* diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py index a094d099896..2d3fd6b970f 100644 --- a/homeassistant/components/london_underground/coordinator.py +++ b/homeassistant/components/london_underground/coordinator.py @@ -3,7 +3,11 @@ from __future__ import annotations import asyncio import logging +from typing import cast +from london_tube_status import TubeData + +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, SCAN_INTERVAL @@ -11,10 +15,10 @@ from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -class LondonTubeCoordinator(DataUpdateCoordinator): +class LondonTubeCoordinator(DataUpdateCoordinator[dict[str, dict[str, str]]]): """London Underground sensor coordinator.""" - def __init__(self, hass, data): + def __init__(self, hass: HomeAssistant, data: TubeData) -> None: """Initialize coordinator.""" super().__init__( hass, @@ -24,7 +28,7 @@ class LondonTubeCoordinator(DataUpdateCoordinator): ) self._data = data - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, dict[str, str]]: async with asyncio.timeout(10): await self._data.update() - return self._data.data + return cast(dict[str, dict[str, str]], self._data.data) diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index c0d0eeca372..3f5ec42521e 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from london_tube_status import TubeData import voluptuous as vol @@ -56,22 +57,22 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): _attr_attribution = "Powered by TfL Open Data" _attr_icon = "mdi:subway" - def __init__(self, coordinator, name): + def __init__(self, coordinator: LondonTubeCoordinator, name: str) -> None: """Initialize the London Underground sensor.""" super().__init__(coordinator) self._name = name @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> str: """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return other details about the sensor state.""" return {"Description": self.coordinator.data[self.name]["Description"]} diff --git a/mypy.ini b/mypy.ini index 67390ef2ddf..f18e781fd23 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1902,6 +1902,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.london_underground.*] +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.lookin.*] check_untyped_defs = true disallow_incomplete_defs = true From 7b1b189f3ef569560f39150f3444498644dbdee6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 26 Sep 2023 08:21:36 +0200 Subject: [PATCH 800/984] Add date range to Workday (#96255) --- .../components/workday/binary_sensor.py | 30 +++- .../components/workday/config_flow.py | 41 ++++- homeassistant/components/workday/strings.json | 10 +- tests/components/workday/__init__.py | 50 ++++++ .../components/workday/test_binary_sensor.py | 55 +++++++ tests/components/workday/test_config_flow.py | 144 ++++++++++++++++++ 6 files changed, 319 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index b60346c3bbb..5daea6ce129 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -5,7 +5,6 @@ from datetime import date, timedelta from typing import Any from holidays import ( - DateLike, HolidayBase, __version__ as python_holidays_version, country_holidays, @@ -45,6 +44,26 @@ from .const import ( ) +def validate_dates(holiday_list: list[str]) -> list[str]: + """Validate and adds to list of dates to add or remove.""" + calc_holidays: list[str] = [] + for add_date in holiday_list: + if add_date.find(",") > 0: + dates = add_date.split(",", maxsplit=1) + d1 = dt_util.parse_date(dates[0]) + d2 = dt_util.parse_date(dates[1]) + if d1 is None or d2 is None: + LOGGER.error("Incorrect dates in date range: %s", add_date) + continue + _range: timedelta = d2 - d1 + for i in range(_range.days + 1): + day = d1 + timedelta(days=i) + calc_holidays.append(day.strftime("%Y-%m-%d")) + continue + calc_holidays.append(add_date) + return calc_holidays + + def valid_country(value: Any) -> str: """Validate that the given country is supported.""" value = cv.string(value) @@ -119,7 +138,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Workday sensor.""" - add_holidays: list[DateLike] = entry.options[CONF_ADD_HOLIDAYS] + add_holidays: list[str] = entry.options[CONF_ADD_HOLIDAYS] remove_holidays: list[str] = entry.options[CONF_REMOVE_HOLIDAYS] country: str | None = entry.options.get(CONF_COUNTRY) days_offset: int = int(entry.options[CONF_OFFSET]) @@ -141,14 +160,17 @@ async def async_setup_entry( else: obj_holidays = HolidayBase() + calc_add_holidays: list[str] = validate_dates(add_holidays) + calc_remove_holidays: list[str] = validate_dates(remove_holidays) + # Add custom holidays try: - obj_holidays.append(add_holidays) + obj_holidays.append(calc_add_holidays) # type: ignore[arg-type] except ValueError as error: LOGGER.error("Could not add custom holidays: %s", error) # Remove holidays - for remove_holiday in remove_holidays: + for remove_holiday in calc_remove_holidays: try: # is this formatted as a date? if dt_util.parse_date(remove_holiday): diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index df74fff83e1..6be7e119876 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -69,10 +69,24 @@ def add_province_to_schema( return vol.Schema({**DATA_SCHEMA_OPT.schema, **add_schema}) +def _is_valid_date_range(check_date: str, error: type[HomeAssistantError]) -> bool: + """Validate date range.""" + if check_date.find(",") > 0: + dates = check_date.split(",", maxsplit=1) + for date in dates: + if dt_util.parse_date(date) is None: + raise error("Incorrect date in range") + return True + return False + + def validate_custom_dates(user_input: dict[str, Any]) -> None: """Validate custom dates for add/remove holidays.""" for add_date in user_input[CONF_ADD_HOLIDAYS]: - if dt_util.parse_date(add_date) is None: + if ( + not _is_valid_date_range(add_date, AddDateRangeError) + and dt_util.parse_date(add_date) is None + ): raise AddDatesError("Incorrect date") year: int = dt_util.now().year @@ -88,9 +102,12 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: obj_holidays = HolidayBase(years=year) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: - if dt_util.parse_date(remove_date) is None: - if obj_holidays.get_named(remove_date) == []: - raise RemoveDatesError("Incorrect date or name") + if ( + not _is_valid_date_range(remove_date, RemoveDateRangeError) + and dt_util.parse_date(remove_date) is None + and obj_holidays.get_named(remove_date) == [] + ): + raise RemoveDatesError("Incorrect date or name") DATA_SCHEMA_SETUP = vol.Schema( @@ -223,8 +240,12 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) except AddDatesError: errors["add_holidays"] = "add_holiday_error" + except AddDateRangeError: + errors["add_holidays"] = "add_holiday_range_error" except RemoveDatesError: errors["remove_holidays"] = "remove_holiday_error" + except RemoveDateRangeError: + errors["remove_holidays"] = "remove_holiday_range_error" except NotImplementedError: self.async_abort(reason="incorrect_province") @@ -284,8 +305,12 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): ) except AddDatesError: errors["add_holidays"] = "add_holiday_error" + except AddDateRangeError: + errors["add_holidays"] = "add_holiday_range_error" except RemoveDatesError: errors["remove_holidays"] = "remove_holiday_error" + except RemoveDateRangeError: + errors["remove_holidays"] = "remove_holiday_range_error" else: LOGGER.debug("abort_check in options with %s", combined_input) try: @@ -328,9 +353,17 @@ class AddDatesError(HomeAssistantError): """Exception for error adding dates.""" +class AddDateRangeError(HomeAssistantError): + """Exception for error adding dates.""" + + class RemoveDatesError(HomeAssistantError): """Exception for error removing dates.""" +class RemoveDateRangeError(HomeAssistantError): + """Exception for error removing dates.""" + + class CountryNotExist(HomeAssistantError): """Exception country does not exist error.""" diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 718f99d7c8a..a4c2baf31c8 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -26,15 +26,17 @@ "excludes": "List of workdays to exclude", "days_offset": "Days offset", "workdays": "List of workdays", - "add_holidays": "Add custom holidays as YYYY-MM-DD", - "remove_holidays": "Remove holidays as YYYY-MM-DD or by using partial of name", + "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", + "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", "province": "State, Territory, Province, Region of Country" } } }, "error": { "add_holiday_error": "Incorrect format on date (YYYY-MM-DD)", - "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found" + "add_holiday_range_error": "Incorrect format on date range (YYYY-MM-DD,YYYY-MM-DD)", + "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found", + "remove_holiday_range_error": "Incorrect format on date range (YYYY-MM-DD,YYYY-MM-DD)" } }, "options": { @@ -61,7 +63,9 @@ }, "error": { "add_holiday_error": "[%key:component::workday::config::error::add_holiday_error%]", + "add_holiday_range_error": "[%key:component::workday::config::error::add_holiday_range_error%]", "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]", + "remove_holiday_range_error": "[%key:component::workday::config::error::remove_holiday_range_error%]", "already_configured": "Service with this configuration already exist" } }, diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index 2a1b61a0a0f..f9e44359b00 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -197,3 +197,53 @@ TEST_CONFIG_INCORRECT_ADD_REMOVE = { "add_holidays": ["2023-12-32"], "remove_holidays": ["2023-12-32"], } +TEST_CONFIG_INCORRECT_ADD_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-01", "2023-12-30,2023-12-32"], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2023-12-25", "2023-12-30,2023-12-32"], +} +TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-01", "2023-12-29,2023-12-30,2023-12-31"], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2023-12-25", "2023-12-29,2023-12-30,2023-12-31"], +} +TEST_CONFIG_ADD_REMOVE_DATE_RANGE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2022-12-01", "2022-12-05,2022-12-15"], + "remove_holidays": ["2022-12-04", "2022-12-24,2022-12-26"], +} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index a3923bfb291..5c387e9a179 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -12,13 +12,18 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC from . import ( + TEST_CONFIG_ADD_REMOVE_DATE_RANGE, TEST_CONFIG_DAY_AFTER_TOMORROW, TEST_CONFIG_EXAMPLE_1, TEST_CONFIG_EXAMPLE_2, TEST_CONFIG_INCLUDE_HOLIDAY, + TEST_CONFIG_INCORRECT_ADD_DATE_RANGE, + TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN, TEST_CONFIG_INCORRECT_ADD_REMOVE, TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE, + TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN, TEST_CONFIG_NO_COUNTRY, TEST_CONFIG_NO_COUNTRY_ADD_HOLIDAY, TEST_CONFIG_NO_PROVINCE, @@ -264,3 +269,53 @@ async def test_setup_incorrect_add_remove( in caplog.text ) assert "No holiday found matching '2023-12-32'" in caplog.text + + +async def test_setup_incorrect_add_holiday_ranges( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove holiday ranges.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_DATE_RANGE) + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_DATE_RANGE_LEN, "2") + + hass.states.get("binary_sensor.workday_sensor") + + assert "Incorrect dates in date range: 2023-12-30,2023-12-32" in caplog.text + assert ( + "Incorrect dates in date range: 2023-12-29,2023-12-30,2023-12-31" in caplog.text + ) + + +async def test_setup_incorrect_remove_holiday_ranges( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove holiday ranges.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE) + await init_integration(hass, TEST_CONFIG_INCORRECT_REMOVE_DATE_RANGE_LEN, "2") + + hass.states.get("binary_sensor.workday_sensor") + + assert "Incorrect dates in date range: 2023-12-30,2023-12-32" in caplog.text + assert ( + "Incorrect dates in date range: 2023-12-29,2023-12-30,2023-12-31" in caplog.text + ) + + +async def test_setup_date_range( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup with date range.""" + freezer.move_to( + datetime(2022, 12, 26, 12, tzinfo=UTC) + ) # Boxing Day should be working day + await init_integration(hass, TEST_CONFIG_ADD_REMOVE_DATE_RANGE) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 78cbbf97fed..65e6c70fa00 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -528,3 +528,147 @@ async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "already_configured"} + + +async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: + """Test errors in setup entry.""" + + 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_NAME: "Workday Sensor", + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-30,2022-12-32"], + CONF_REMOVE_HOLIDAYS: [], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + assert result3["errors"] == {"add_holidays": "add_holiday_range_error"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12"], + CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-32"], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["errors"] == {"remove_holidays": "remove_holiday_range_error"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-01,2022-12-10"], + CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-31"], + CONF_PROVINCE: "none", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-12", "2022-12-01,2022-12-10"], + "remove_holidays": ["2022-12-25", "2022-12-30,2022-12-31"], + "province": None, + } + + +async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: + """Test errors in options.""" + + entry = await init_integration( + hass, + { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], + "province": None, + }, + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-32"], + "remove_holidays": [], + "province": "BW", + }, + ) + + assert result2["errors"] == {"add_holidays": "add_holiday_range_error"} + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-13-25,2022-12-26"], + "province": "BW", + }, + ) + + assert result2["errors"] == {"remove_holidays": "remove_holiday_range_error"} + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-12-25,2022-12-26"], + "province": "BW", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "name": "Workday Sensor", + "country": "DE", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": ["2022-12-30,2022-12-31"], + "remove_holidays": ["2022-12-25,2022-12-26"], + "province": "BW", + } From 8ba6fd79350f010b9cb8abf18df754e9bbf0a804 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Tue, 26 Sep 2023 03:15:20 -0400 Subject: [PATCH 801/984] Add device info to Hydrawise (#100828) * Add device info to Hydrawise * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Remove _attr_has_entity_name --------- Co-authored-by: Joost Lekkerkerker --- .../components/hydrawise/__init__.py | 6 +- .../components/hydrawise/binary_sensor.py | 1 + homeassistant/components/hydrawise/const.py | 2 + homeassistant/components/hydrawise/entity.py | 16 ++-- tests/components/hydrawise/conftest.py | 91 ++++++++++++++++++- tests/components/hydrawise/test_device.py | 36 ++++++++ 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 tests/components/hydrawise/test_device.py diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 560046e9c2b..bc3c62cfb9f 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,7 +1,7 @@ """Support for Hydrawise cloud.""" -from pydrawise.legacy import LegacyHydrawise +from pydrawise import legacy from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol @@ -54,7 +54,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up Hydrawise from a config entry.""" access_token = config_entry.data[CONF_API_KEY] try: - hydrawise = await hass.async_add_executor_job(LegacyHydrawise, access_token) + 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( diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 06683ff0345..8a5d6fe2f83 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -77,6 +77,7 @@ async def async_setup_entry( data=hydrawise.current_controller, coordinator=coordinator, description=BINARY_SENSOR_STATUS, + device_id_key="controller_id", ) ] diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index ccf3eb5bac0..dc53d847b1f 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -11,6 +11,8 @@ CONF_WATERING_TIME = "watering_minutes" DOMAIN = "hydrawise" DEFAULT_WATERING_TIME = 15 +MANUFACTURER = "Hydrawise" + SCAN_INTERVAL = timedelta(seconds=120) SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 98b66069913..db07faef6d0 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -3,9 +3,11 @@ from __future__ import annotations from typing import Any +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN, MANUFACTURER from .coordinator import HydrawiseDataUpdateCoordinator @@ -20,14 +22,16 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): data: dict[str, Any], coordinator: HydrawiseDataUpdateCoordinator, description: EntityDescription, + device_id_key: str = "relay_id", ) -> None: """Initialize the Hydrawise entity.""" super().__init__(coordinator=coordinator) self.data = data self.entity_description = description - self._attr_name = f"{self.data['name']} {description.name}" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return {"identifier": self.data.get("relay")} + self._device_id = str(data.get(device_id_key)) + self._attr_unique_id = f"{self._device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=data["name"], + manufacturer=MANUFACTURER, + ) diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index b6e22ec7b80..30989018152 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -1,10 +1,17 @@ """Common fixtures for the Hydrawise tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from typing import Any +from unittest.mock import AsyncMock, Mock, patch import pytest +from homeassistant.components.hydrawise.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -13,3 +20,85 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.hydrawise.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_pydrawise( + mock_controller: dict[str, Any], + mock_zones: list[dict[str, Any]], +) -> Generator[Mock, None, None]: + """Mock LegacyHydrawise.""" + with patch("pydrawise.legacy.LegacyHydrawise", autospec=True) as mock_pydrawise: + mock_pydrawise.return_value.controller_info = {"controllers": [mock_controller]} + mock_pydrawise.return_value.current_controller = mock_controller + mock_pydrawise.return_value.controller_status = {"relays": mock_zones} + mock_pydrawise.return_value.relays = mock_zones + yield mock_pydrawise.return_value + + +@pytest.fixture +def mock_controller() -> dict[str, Any]: + """Mock Hydrawise controller.""" + return { + "name": "Home Controller", + "last_contact": 1693292420, + "serial_number": "0310b36090", + "controller_id": 52496, + "status": "Unknown", + } + + +@pytest.fixture +def mock_zones() -> list[dict[str, Any]]: + """Mock Hydrawise zones.""" + return [ + { + "name": "Zone One", + "period": 259200, + "relay": 1, + "relay_id": 5965394, + "run": 1800, + "stop": 1, + "time": 330597, + "timestr": "Sat", + "type": 1, + }, + { + "name": "Zone Two", + "period": 259200, + "relay": 2, + "relay_id": 5965395, + "run": 1788, + "stop": 1, + "time": 1, + "timestr": "Now", + "type": 106, + }, + ] + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="Hydrawise", + domain=DOMAIN, + data={ + CONF_API_KEY: "abc123", + }, + unique_id="hydrawise-customerid", + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pydrawise: Mock, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + 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 DOMAIN in hass.config_entries.async_domains() + return mock_config_entry diff --git a/tests/components/hydrawise/test_device.py b/tests/components/hydrawise/test_device.py new file mode 100644 index 00000000000..05c402faca7 --- /dev/null +++ b/tests/components/hydrawise/test_device.py @@ -0,0 +1,36 @@ +"""Tests for Hydrawise devices.""" + +from unittest.mock import Mock + +from homeassistant.components.hydrawise.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +def test_zones_in_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock +) -> None: + """Test that devices are added to the device registry.""" + device_registry = dr.async_get(hass) + + device1 = device_registry.async_get_device(identifiers={(DOMAIN, "5965394")}) + assert device1 is not None + assert device1.name == "Zone One" + assert device1.manufacturer == "Hydrawise" + + device2 = device_registry.async_get_device(identifiers={(DOMAIN, "5965395")}) + assert device2 is not None + assert device2.name == "Zone Two" + assert device2.manufacturer == "Hydrawise" + + +def test_controller_in_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry, mock_pydrawise: Mock +) -> None: + """Test that devices are added to the device registry.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, "52496")}) + assert device is not None + assert device.name == "Home Controller" + assert device.manufacturer == "Hydrawise" From 4f63c7934ba1f6991e04e8ea5b3869064a2a4685 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 09:17:11 +0200 Subject: [PATCH 802/984] Add coordinator to Withings (#100378) * Add coordinator to Withings * Add coordinator to Withings * Fix tests * Remove common files * Fix tests * Fix tests * Rename to Entity * Fix * Rename webhook handler * Fix * Fix external url * Update homeassistant/components/withings/entity.py Co-authored-by: Luke Lashley * Update homeassistant/components/withings/entity.py Co-authored-by: Luke Lashley * Update homeassistant/components/withings/entity.py Co-authored-by: Luke Lashley * Update homeassistant/components/withings/entity.py Co-authored-by: Luke Lashley * fix imports * Simplify * Simplify * Fix feedback * Test if this makes changes clearer * Test if this makes changes clearer * Fix tests * Remove name * Fix feedback --------- Co-authored-by: Luke Lashley --- homeassistant/components/withings/__init__.py | 144 ++++--- .../components/withings/binary_sensor.py | 23 +- homeassistant/components/withings/common.py | 368 ++++-------------- homeassistant/components/withings/entity.py | 78 +--- homeassistant/components/withings/sensor.py | 56 +-- tests/components/withings/__init__.py | 8 +- tests/components/withings/common.py | 328 ---------------- tests/components/withings/conftest.py | 54 +-- .../components/withings/test_binary_sensor.py | 35 +- tests/components/withings/test_config_flow.py | 26 +- tests/components/withings/test_init.py | 96 ++++- tests/components/withings/test_sensor.py | 52 ++- 12 files changed, 393 insertions(+), 875 deletions(-) delete mode 100644 tests/components/withings/common.py diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 5e733708639..1c66115d9b5 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -4,8 +4,9 @@ For more details about this platform, please refer to the documentation at """ from __future__ import annotations -import asyncio +from collections.abc import Awaitable, Callable +from aiohttp.hdrs import METH_HEAD, METH_POST from aiohttp.web import Request, Response import voluptuous as vol from withings_api.common import NotifyAppli @@ -15,6 +16,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import ( async_generate_id, async_unregister as async_unregister_webhook, @@ -28,17 +30,13 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from . import const -from .common import ( - async_get_data_manager, - async_remove_data_manager, - get_data_manager_by_webhook_id, - json_message_response, -) +from .api import ConfigEntryWithingsApi +from .common import WithingsDataUpdateCoordinator from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER DOMAIN = const.DOMAIN @@ -56,7 +54,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_CLIENT_SECRET): vol.All( cv.string, vol.Length(min=1) ), - vol.Optional(const.CONF_USE_WEBHOOK, default=False): cv.boolean, + vol.Optional(const.CONF_USE_WEBHOOK): cv.boolean, vol.Optional(const.CONF_PROFILES): vol.All( cv.ensure_list, vol.Unique(), @@ -116,37 +114,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry( entry, data=new_data, options=new_options, unique_id=unique_id ) - use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK] - if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: + if ( + use_webhook := hass.data[DOMAIN][CONFIG].get(CONF_USE_WEBHOOK) + ) is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: new_options = entry.options.copy() new_options |= {CONF_USE_WEBHOOK: use_webhook} hass.config_entries.async_update_entry(entry, options=new_options) - data_manager = await async_get_data_manager(hass, entry) - - LOGGER.debug("Confirming %s is authenticated to withings", entry.title) - await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() - - webhook.async_register( - hass, - const.DOMAIN, - "Withings notify", - data_manager.webhook_config.id, - async_webhook_handler, + client = ConfigEntryWithingsApi( + hass=hass, + config_entry=entry, + implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ), ) - # Perform first webhook subscription check. - if data_manager.webhook_config.enabled: - data_manager.async_start_polling_webhook_subscriptions() + use_webhooks = entry.options[CONF_USE_WEBHOOK] + coordinator = WithingsDataUpdateCoordinator(hass, client, use_webhooks) + if use_webhooks: @callback def async_call_later_callback(now) -> None: - hass.async_create_task( - data_manager.subscription_update_coordinator.async_refresh() - ) + hass.async_create_task(coordinator.async_subscribe_webhooks()) - # Start subscription check in the background, outside this component's setup. entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) + webhook.async_register( + hass, + DOMAIN, + "Withings notify", + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(coordinator), + ) + + 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) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -156,19 +158,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Withings config entry.""" - data_manager = await async_get_data_manager(hass, entry) - data_manager.async_stop_polling_webhook_subscriptions() + if entry.options[CONF_USE_WEBHOOK]: + async_unregister_webhook(hass, entry.data[CONF_WEBHOOK_ID]) - async_unregister_webhook(hass, data_manager.webhook_config.id) - - await asyncio.gather( - data_manager.async_unsubscribe_webhook(), - hass.config_entries.async_unload_platforms(entry, PLATFORMS), - ) - - async_remove_data_manager(hass, entry) - - return True + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -176,44 +171,45 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -async def async_webhook_handler( - hass: HomeAssistant, webhook_id: str, request: Request -) -> Response | None: - """Handle webhooks calls.""" - # Handle http head calls to the path. - # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. - if request.method.upper() == "HEAD": - return Response() +def json_message_response(message: str, message_code: int) -> Response: + """Produce common json output.""" + return HomeAssistantView.json({"message": message, "code": message_code}) - if request.method.upper() != "POST": - return json_message_response("Invalid method", message_code=2) - # Handle http post calls to the path. - if not request.body_exists: - return json_message_response("No request body", message_code=12) +def get_webhook_handler( + coordinator: WithingsDataUpdateCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" - params = await request.post() + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http head calls to the path. + # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. + if request.method == METH_HEAD: + return Response() - if "appli" not in params: - return json_message_response("Parameter appli not provided", message_code=20) + if request.method != METH_POST: + return json_message_response("Invalid method", message_code=2) - try: - appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] - except ValueError: - return json_message_response("Invalid appli provided", message_code=21) + # Handle http post calls to the path. + if not request.body_exists: + return json_message_response("No request body", message_code=12) - data_manager = get_data_manager_by_webhook_id(hass, webhook_id) - if not data_manager: - LOGGER.error( - ( - "Webhook id %s not handled by data manager. This is a bug and should be" - " reported" - ), - webhook_id, - ) - return json_message_response("User not found", message_code=1) + params = await request.post() - # Run this in the background and return immediately. - hass.async_create_task(data_manager.async_webhook_data_updated(appli)) + if "appli" not in params: + return json_message_response( + "Parameter appli not provided", message_code=20 + ) - return json_message_response("Success", message_code=0) + try: + appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] + except ValueError: + return json_message_response("Invalid appli provided", message_code=21) + + await coordinator.async_webhook_data_updated(appli) + + return json_message_response("Success", message_code=0) + + return async_webhook_handler diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 976774f23b3..e12a0929c2a 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import UpdateType, async_get_data_manager -from .const import Measurement -from .entity import BaseWithingsSensor, WithingsEntityDescription +from .common import WithingsDataUpdateCoordinator +from .const import DOMAIN, Measurement +from .entity import WithingsEntity, WithingsEntityDescription @dataclass @@ -34,7 +34,6 @@ BINARY_SENSORS = [ measure_type=NotifyAppli.BED_IN, translation_key="in_bed", icon="mdi:bed", - update_type=UpdateType.WEBHOOK, device_class=BinarySensorDeviceClass.OCCUPANCY, ), ] @@ -46,17 +45,17 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - data_manager = await async_get_data_manager(hass, entry) + coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [ - WithingsHealthBinarySensor(data_manager, attribute) - for attribute in BINARY_SENSORS - ] + if coordinator.use_webhooks: + entities = [ + WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS + ] - async_add_entities(entities, True) + async_add_entities(entities) -class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): +class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): """Implementation of a Withings sensor.""" entity_description: WithingsBinarySensorEntityDescription @@ -64,4 +63,4 @@ class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self._state_data + return self.coordinator.in_bed diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 5f0090ad9a6..08d330f7d5b 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -1,17 +1,9 @@ -"""Common code for Withings.""" -from __future__ import annotations - +"""Withings coordinator.""" import asyncio from collections.abc import Callable -from dataclasses import dataclass -import datetime from datetime import timedelta -from enum import IntEnum, StrEnum -from http import HTTPStatus -import re from typing import Any -from aiohttp.web import Response from withings_api.common import ( AuthFailedException, GetSleepSummaryField, @@ -23,43 +15,19 @@ from withings_api.common import ( query_measure_groups, ) -from homeassistant.components import webhook -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.webhook import async_generate_url from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import const from .api import ConfigEntryWithingsApi from .const import LOGGER, Measurement -NOT_AUTHENTICATED_ERROR = re.compile( - f"^{HTTPStatus.UNAUTHORIZED},.*", - re.IGNORECASE, -) -DATA_UPDATED_SIGNAL = "withings_entity_state_updated" -SUBSCRIBE_DELAY = datetime.timedelta(seconds=5) -UNSUBSCRIBE_DELAY = datetime.timedelta(seconds=1) - - -class UpdateType(StrEnum): - """Data update type.""" - - POLL = "poll" - WEBHOOK = "webhook" - - -@dataclass -class WebhookConfig: - """Config for a webhook.""" - - id: str - url: str - enabled: bool - +SUBSCRIBE_DELAY = timedelta(seconds=5) +UNSUBSCRIBE_DELAY = timedelta(seconds=1) WITHINGS_MEASURE_TYPE_MAP: dict[ NotifyAppli | GetSleepSummaryField | MeasureType, Measurement @@ -105,214 +73,91 @@ WITHINGS_MEASURE_TYPE_MAP: dict[ } -def json_message_response(message: str, message_code: int) -> Response: - """Produce common json output.""" - return HomeAssistantView.json({"message": message, "code": message_code}) +class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): + """Base coordinator.""" - -class WebhookAvailability(IntEnum): - """Represents various statuses of webhook availability.""" - - SUCCESS = 0 - CONNECT_ERROR = 1 - HTTP_ERROR = 2 - NOT_WEBHOOK = 3 - - -class WebhookUpdateCoordinator: - """Coordinates webhook data updates across listeners.""" - - def __init__(self, hass: HomeAssistant, user_id: int) -> None: - """Initialize the object.""" - self._hass = hass - self._user_id = user_id - self._listeners: list[CALLBACK_TYPE] = [] - self.data: dict[Measurement, Any] = {} - - def async_add_listener(self, listener: CALLBACK_TYPE) -> Callable[[], None]: - """Add a listener.""" - self._listeners.append(listener) - - @callback - def remove_listener() -> None: - self.async_remove_listener(listener) - - return remove_listener - - def async_remove_listener(self, listener: CALLBACK_TYPE) -> None: - """Remove a listener.""" - self._listeners.remove(listener) - - def update_data(self, measurement: Measurement, value: Any) -> None: - """Update the data object and notify listeners the data has changed.""" - self.data[measurement] = value - self.notify_data_changed() - - def notify_data_changed(self) -> None: - """Notify all listeners the data has changed.""" - for listener in self._listeners: - listener() - - -class DataManager: - """Manage withing data.""" + in_bed: bool | None = None + config_entry: ConfigEntry def __init__( - self, - hass: HomeAssistant, - api: ConfigEntryWithingsApi, - user_id: int, - webhook_config: WebhookConfig, + self, hass: HomeAssistant, client: ConfigEntryWithingsApi, use_webhooks: bool ) -> None: - """Initialize the data manager.""" - self._hass = hass - self._api = api - self._user_id = user_id - self._webhook_config = webhook_config - self._notify_subscribe_delay = SUBSCRIBE_DELAY - self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY - - self._is_available = True - self._cancel_interval_update_interval: CALLBACK_TYPE | None = None - self._cancel_configure_webhook_subscribe_interval: CALLBACK_TYPE | None = None - self._api_notification_id = f"withings_{self._user_id}" - - self.subscription_update_coordinator = DataUpdateCoordinator( - hass, - LOGGER, - name="subscription_update_coordinator", - update_interval=timedelta(minutes=120), - update_method=self.async_subscribe_webhook, + """Initialize the Withings data coordinator.""" + update_interval: timedelta | None = timedelta(minutes=10) + if use_webhooks: + update_interval = None + super().__init__(hass, LOGGER, name="Withings", update_interval=update_interval) + self._client = client + self._webhook_url = async_generate_url( + hass, self.config_entry.data[CONF_WEBHOOK_ID] ) - self.poll_data_update_coordinator = DataUpdateCoordinator[ - dict[MeasureType, Any] | None - ]( - hass, - LOGGER, - name="poll_data_update_coordinator", - update_interval=timedelta(minutes=120) - if self._webhook_config.enabled - else timedelta(minutes=10), - update_method=self.async_get_all_data, - ) - self.webhook_update_coordinator = WebhookUpdateCoordinator( - self._hass, self._user_id - ) - self._cancel_subscription_update: Callable[[], None] | None = None - self._subscribe_webhook_run_count = 0 + self.use_webhooks = use_webhooks - @property - def webhook_config(self) -> WebhookConfig: - """Get the webhook config.""" - return self._webhook_config + async def async_subscribe_webhooks(self) -> None: + """Subscribe to webhooks.""" + await self.async_unsubscribe_webhooks() - @property - def user_id(self) -> int: - """Get the user_id of the authenticated user.""" - return self._user_id + current_webhooks = await self._client.async_notify_list() - def async_start_polling_webhook_subscriptions(self) -> None: - """Start polling webhook subscriptions (if enabled) to reconcile their setup.""" - self.async_stop_polling_webhook_subscriptions() - - def empty_listener() -> None: - pass - - self._cancel_subscription_update = ( - self.subscription_update_coordinator.async_add_listener(empty_listener) - ) - - def async_stop_polling_webhook_subscriptions(self) -> None: - """Stop polling webhook subscriptions.""" - if self._cancel_subscription_update: - self._cancel_subscription_update() - self._cancel_subscription_update = None - - async def async_subscribe_webhook(self) -> None: - """Subscribe the webhook to withings data updates.""" - LOGGER.debug("Configuring withings webhook") - - # On first startup, perform a fresh re-subscribe. Withings stops pushing data - # if the webhook fails enough times but they don't remove the old subscription - # config. This ensures the subscription is setup correctly and they start - # pushing again. - if self._subscribe_webhook_run_count == 0: - LOGGER.debug("Refreshing withings webhook configs") - await self.async_unsubscribe_webhook() - self._subscribe_webhook_run_count += 1 - - # Get the current webhooks. - response = await self._api.async_notify_list() - - subscribed_applis = frozenset( + subscribed_notifications = frozenset( profile.appli - for profile in response.profiles - if profile.callbackurl == self._webhook_config.url + for profile in current_webhooks.profiles + if profile.callbackurl == self._webhook_url ) - # Determine what subscriptions need to be created. - ignored_applis = frozenset({NotifyAppli.USER, NotifyAppli.UNKNOWN}) - to_add_applis = frozenset( - appli - for appli in NotifyAppli - if appli not in subscribed_applis and appli not in ignored_applis + notification_to_subscribe = ( + set(NotifyAppli) + - subscribed_notifications + - {NotifyAppli.USER, NotifyAppli.UNKNOWN} ) - # Subscribe to each one. - for appli in to_add_applis: + for notification in notification_to_subscribe: LOGGER.debug( "Subscribing %s for %s in %s seconds", - self._webhook_config.url, - appli, - self._notify_subscribe_delay.total_seconds(), + self._webhook_url, + notification, + SUBSCRIBE_DELAY.total_seconds(), ) # Withings will HTTP HEAD the callback_url and needs some downtime # between each call or there is a higher chance of failure. - await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._api.async_notify_subscribe(self._webhook_config.url, appli) + await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) + await self._client.async_notify_subscribe(self._webhook_url, notification) - async def async_unsubscribe_webhook(self) -> None: - """Unsubscribe webhook from withings data updates.""" - # Get the current webhooks. - response = await self._api.async_notify_list() + async def async_unsubscribe_webhooks(self) -> None: + """Unsubscribe to webhooks.""" + current_webhooks = await self._client.async_notify_list() - # Revoke subscriptions. - for profile in response.profiles: + for webhook_configuration in current_webhooks.profiles: LOGGER.debug( "Unsubscribing %s for %s in %s seconds", - profile.callbackurl, - profile.appli, - self._notify_unsubscribe_delay.total_seconds(), + webhook_configuration.callbackurl, + webhook_configuration.appli, + UNSUBSCRIBE_DELAY.total_seconds(), ) # Quick calls to Withings can result in the service returning errors. # Give them some time to cool down. - await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._api.async_notify_revoke(profile.callbackurl, profile.appli) + await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) + await self._client.async_notify_revoke( + webhook_configuration.callbackurl, webhook_configuration.appli + ) - async def async_get_all_data(self) -> dict[MeasureType, Any] | None: - """Update all withings data.""" + async def _async_update_data(self) -> dict[Measurement, Any]: try: - return { - **await self.async_get_measures(), - **await self.async_get_sleep_summary(), - } - except Exception as exception: - # User is not authenticated. - if isinstance( - exception, (UnauthorizedException, AuthFailedException) - ) or NOT_AUTHENTICATED_ERROR.match(str(exception)): - self._api.config_entry.async_start_reauth(self._hass) - return None + measurements = await self._get_measurements() + sleep_summary = await self._get_sleep_summary() + except (UnauthorizedException, AuthFailedException) as exc: + raise ConfigEntryAuthFailed from exc + return { + **measurements, + **sleep_summary, + } - raise exception - - async def async_get_measures(self) -> dict[Measurement, Any]: - """Get the measures data.""" + async def _get_measurements(self) -> dict[Measurement, Any]: LOGGER.debug("Updating withings measures") now = dt_util.utcnow() - startdate = now - datetime.timedelta(days=7) + startdate = now - timedelta(days=7) - response = await self._api.async_measure_get_meas( + response = await self._client.async_measure_get_meas( None, None, startdate, now, None, startdate ) @@ -334,17 +179,13 @@ class DataManager: if measure.type in WITHINGS_MEASURE_TYPE_MAP } - async def async_get_sleep_summary(self) -> dict[Measurement, Any]: - """Get the sleep summary data.""" - LOGGER.debug("Updating withing sleep summary") + async def _get_sleep_summary(self) -> dict[Measurement, Any]: now = dt_util.now() - yesterday = now - datetime.timedelta(days=1) - yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta( - hours=12 - ) + yesterday = now - timedelta(days=1) + yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12) yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - response = await self._api.async_sleep_get_summary( + response = await self._client.async_sleep_get_summary( lastupdate=yesterday_noon_utc, data_fields=[ GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, @@ -415,81 +256,18 @@ class DataManager: for field, value in values.items() } - async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: - """Handle scenario when data is updated from a webook.""" + async def async_webhook_data_updated( + self, notification_category: NotifyAppli + ) -> None: + """Update data when webhook is called.""" LOGGER.debug("Withings webhook triggered") - if data_category in { + if notification_category in { NotifyAppli.WEIGHT, NotifyAppli.CIRCULATORY, NotifyAppli.SLEEP, }: - await self.poll_data_update_coordinator.async_request_refresh() + await self.async_request_refresh() - elif data_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: - self.webhook_update_coordinator.update_data( - Measurement.IN_BED, data_category == NotifyAppli.BED_IN - ) - - -async def async_get_data_manager( - hass: HomeAssistant, config_entry: ConfigEntry -) -> DataManager: - """Get the data manager for a config entry.""" - hass.data.setdefault(const.DOMAIN, {}) - hass.data[const.DOMAIN].setdefault(config_entry.entry_id, {}) - config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] - - if const.DATA_MANAGER not in config_entry_data: - LOGGER.debug( - "Creating withings data manager for profile: %s", config_entry.title - ) - config_entry_data[const.DATA_MANAGER] = DataManager( - hass, - ConfigEntryWithingsApi( - hass=hass, - config_entry=config_entry, - implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, config_entry - ), - ), - config_entry.data["token"]["userid"], - WebhookConfig( - id=config_entry.data[CONF_WEBHOOK_ID], - url=webhook.async_generate_url( - hass, config_entry.data[CONF_WEBHOOK_ID] - ), - enabled=config_entry.options[const.CONF_USE_WEBHOOK], - ), - ) - - return config_entry_data[const.DATA_MANAGER] - - -def get_data_manager_by_webhook_id( - hass: HomeAssistant, webhook_id: str -) -> DataManager | None: - """Get a data manager by it's webhook id.""" - return next( - iter( - [ - data_manager - for data_manager in get_all_data_managers(hass) - if data_manager.webhook_config.id == webhook_id - ] - ), - None, - ) - - -def get_all_data_managers(hass: HomeAssistant) -> tuple[DataManager, ...]: - """Get all configured data managers.""" - return tuple( - config_entry_data[const.DATA_MANAGER] - for config_entry_data in hass.data[const.DOMAIN].values() - if const.DATA_MANAGER in config_entry_data - ) - - -def async_remove_data_manager(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Remove a data manager for a config entry.""" - del hass.data[const.DOMAIN][config_entry.entry_id][const.DATA_MANAGER] + elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: + self.in_bed = notification_category == NotifyAppli.BED_IN + self.async_update_listeners() diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index f17d3ccf03c..855162c4616 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -2,15 +2,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli -from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .common import DataManager, UpdateType +from .common import WithingsDataUpdateCoordinator from .const import DOMAIN, Measurement @@ -20,7 +19,6 @@ class WithingsEntityDescriptionMixin: measurement: Measurement measure_type: NotifyAppli | GetSleepSummaryField | MeasureType - update_type: UpdateType @dataclass @@ -28,72 +26,22 @@ class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixi """Immutable class for describing withings data.""" -class BaseWithingsSensor(Entity): - """Base class for withings sensors.""" +class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): + """Base class for withings entities.""" - _attr_should_poll = False entity_description: WithingsEntityDescription _attr_has_entity_name = True def __init__( - self, data_manager: DataManager, description: WithingsEntityDescription + self, + coordinator: WithingsDataUpdateCoordinator, + description: WithingsEntityDescription, ) -> None: - """Initialize the Withings sensor.""" - self._data_manager = data_manager + """Initialize the Withings entity.""" + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = ( - f"withings_{data_manager.user_id}_{description.measurement.value}" - ) - self._state_data: Any | None = None + self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, manufacturer="Withings" + identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, + manufacturer="Withings", ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.entity_description.update_type == UpdateType.POLL: - return self._data_manager.poll_data_update_coordinator.last_update_success - - if self.entity_description.update_type == UpdateType.WEBHOOK: - return self._data_manager.webhook_config.enabled and ( - self.entity_description.measurement - in self._data_manager.webhook_update_coordinator.data - ) - - return True - - @callback - def _on_poll_data_updated(self) -> None: - self._update_state_data( - self._data_manager.poll_data_update_coordinator.data or {} - ) - - @callback - def _on_webhook_data_updated(self) -> None: - self._update_state_data( - self._data_manager.webhook_update_coordinator.data or {} - ) - - def _update_state_data(self, data: dict[Measurement, Any]) -> None: - """Update the state data.""" - self._state_data = data.get(self.entity_description.measurement) - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register update dispatcher.""" - if self.entity_description.update_type == UpdateType.POLL: - self.async_on_remove( - self._data_manager.poll_data_update_coordinator.async_add_listener( - self._on_poll_data_updated - ) - ) - self._on_poll_data_updated() - - elif self.entity_description.update_type == UpdateType.WEBHOOK: - self.async_on_remove( - self._data_manager.webhook_update_coordinator.async_add_listener( - self._on_webhook_data_updated - ) - ) - self._on_webhook_data_updated() diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index e8798adae2f..7b867ad0cff 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -23,8 +23,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import UpdateType, async_get_data_manager +from .common import WithingsDataUpdateCoordinator from .const import ( + DOMAIN, SCORE_POINTS, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, @@ -32,7 +33,7 @@ from .const import ( UOM_MMHG, Measurement, ) -from .entity import BaseWithingsSensor, WithingsEntityDescription +from .entity import WithingsEntity, WithingsEntityDescription @dataclass @@ -50,7 +51,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_MASS_KG.value, @@ -60,7 +60,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_FREE_MASS_KG.value, @@ -70,7 +69,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.MUSCLE_MASS_KG.value, @@ -80,7 +78,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.BONE_MASS_KG.value, @@ -90,7 +87,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HEIGHT_M.value, @@ -101,7 +97,6 @@ SENSORS = [ device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.TEMP_C.value, @@ -110,7 +105,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.BODY_TEMP_C.value, @@ -120,7 +114,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SKIN_TEMP_C.value, @@ -130,7 +123,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.FAT_RATIO_PCT.value, @@ -139,7 +131,6 @@ SENSORS = [ translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.DIASTOLIC_MMHG.value, @@ -148,7 +139,6 @@ SENSORS = [ translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SYSTOLIC_MMGH.value, @@ -157,7 +147,6 @@ SENSORS = [ translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HEART_PULSE_BPM.value, @@ -167,7 +156,6 @@ SENSORS = [ native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SPO2_PCT.value, @@ -176,7 +164,6 @@ SENSORS = [ translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.HYDRATION.value, @@ -188,7 +175,6 @@ SENSORS = [ icon="mdi:water", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.PWV.value, @@ -198,7 +184,6 @@ SENSORS = [ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, @@ -207,7 +192,6 @@ SENSORS = [ translation_key="breathing_disturbances_intensity", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value, @@ -219,7 +203,6 @@ SENSORS = [ device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value, @@ -231,7 +214,6 @@ SENSORS = [ device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value, @@ -243,7 +225,6 @@ SENSORS = [ device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_AVERAGE.value, @@ -254,7 +235,6 @@ SENSORS = [ icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MAX.value, @@ -266,7 +246,6 @@ SENSORS = [ icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MIN.value, @@ -277,7 +256,6 @@ SENSORS = [ icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value, @@ -289,7 +267,6 @@ SENSORS = [ device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_REM_DURATION_SECONDS.value, @@ -301,7 +278,6 @@ SENSORS = [ device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value, @@ -311,7 +287,6 @@ SENSORS = [ native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value, @@ -321,7 +296,6 @@ SENSORS = [ native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value, @@ -331,7 +305,6 @@ SENSORS = [ native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SCORE.value, @@ -342,7 +315,6 @@ SENSORS = [ icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING.value, @@ -351,7 +323,6 @@ SENSORS = [ translation_key="snoring", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value, @@ -360,7 +331,6 @@ SENSORS = [ translation_key="snoring_episode_count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_COUNT.value, @@ -371,7 +341,6 @@ SENSORS = [ icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value, @@ -383,7 +352,6 @@ SENSORS = [ device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - update_type=UpdateType.POLL, ), ] @@ -394,14 +362,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - data_manager = await async_get_data_manager(hass, entry) + coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [WithingsHealthSensor(data_manager, attribute) for attribute in SENSORS] - - async_add_entities(entities, True) + async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS) -class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): +class WithingsSensor(WithingsEntity, SensorEntity): """Implementation of a Withings sensor.""" entity_description: WithingsSensorEntityDescription @@ -409,4 +375,12 @@ class WithingsHealthSensor(BaseWithingsSensor, SensorEntity): @property def native_value(self) -> None | str | int | float: """Return the state of the entity.""" - return self._state_data + return self.coordinator.data[self.entity_description.measurement] + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return ( + super().available + and self.entity_description.measurement in self.coordinator.data + ) diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 4634a77a8da..e6fb24244d6 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Any from urllib.parse import urlparse +from aiohttp.test_utils import TestClient + from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config import async_process_ha_core_config @@ -21,7 +23,7 @@ class WebhookResponse: async def call_webhook( - hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client + hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client: TestClient ) -> WebhookResponse: """Call the webhook.""" webhook_url = async_generate_url(hass, webhook_id) @@ -34,7 +36,7 @@ async def call_webhook( # Wait for remaining tasks to complete. await hass.async_block_till_done() - data: dict[str, Any] = await resp.json() + data = await resp.json() resp.close() return WebhookResponse(message=data["message"], message_code=data["code"]) @@ -46,7 +48,7 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await async_process_ha_core_config( hass, - {"internal_url": "http://example.local:8123"}, + {"external_url": "http://example.local:8123"}, ) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py deleted file mode 100644 index 7680b19e289..00000000000 --- a/tests/components/withings/common.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Common data for for the withings component tests.""" -from __future__ import annotations - -from dataclasses import dataclass -from http import HTTPStatus -from unittest.mock import MagicMock -from urllib.parse import urlparse - -from aiohttp.test_utils import TestClient -import arrow -from withings_api.common import ( - MeasureGetMeasResponse, - NotifyAppli, - NotifyListResponse, - SleepGetSummaryResponse, - UserGetDeviceResponse, -) - -from homeassistant import data_entry_flow -import homeassistant.components.api as api -from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN -import homeassistant.components.webhook as webhook -from homeassistant.components.withings.common import ( - ConfigEntryWithingsApi, - DataManager, - get_all_data_managers, -) -import homeassistant.components.withings.const as const -from homeassistant.components.withings.entity import WithingsEntityDescription -from homeassistant.config import async_process_ha_core_config -from homeassistant.config_entries import SOURCE_USER, ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, entity_registry as er -from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH -from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from tests.common import MockConfigEntry -from tests.components.withings import WebhookResponse -from tests.test_util.aiohttp import AiohttpClientMocker - - -@dataclass -class ProfileConfig: - """Data representing a user profile.""" - - profile: str - user_id: int - api_response_user_get_device: UserGetDeviceResponse | Exception - api_response_measure_get_meas: MeasureGetMeasResponse | Exception - api_response_sleep_get_summary: SleepGetSummaryResponse | Exception - api_response_notify_list: NotifyListResponse | Exception - api_response_notify_revoke: Exception | None - - -def new_profile_config( - profile: str, - user_id: int, - api_response_user_get_device: UserGetDeviceResponse | Exception | None = None, - api_response_measure_get_meas: MeasureGetMeasResponse | Exception | None = None, - api_response_sleep_get_summary: SleepGetSummaryResponse | Exception | None = None, - api_response_notify_list: NotifyListResponse | Exception | None = None, - api_response_notify_revoke: Exception | None = None, -) -> ProfileConfig: - """Create a new profile config immutable object.""" - return ProfileConfig( - profile=profile, - user_id=user_id, - api_response_user_get_device=api_response_user_get_device - or UserGetDeviceResponse(devices=[]), - api_response_measure_get_meas=api_response_measure_get_meas - or MeasureGetMeasResponse( - measuregrps=[], - more=False, - offset=0, - timezone=dt_util.UTC, - updatetime=arrow.get(12345), - ), - api_response_sleep_get_summary=api_response_sleep_get_summary - or SleepGetSummaryResponse(more=False, offset=0, series=[]), - api_response_notify_list=api_response_notify_list - or NotifyListResponse(profiles=[]), - api_response_notify_revoke=api_response_notify_revoke, - ) - - -class ComponentFactory: - """Manages the setup and unloading of the withing component and profiles.""" - - def __init__( - self, - hass: HomeAssistant, - api_class_mock: MagicMock, - hass_client_no_auth, - aioclient_mock: AiohttpClientMocker, - ) -> None: - """Initialize the object.""" - self._hass = hass - self._api_class_mock = api_class_mock - self._hass_client = hass_client_no_auth - self._aioclient_mock = aioclient_mock - self._client_id = None - self._client_secret = None - self._profile_configs: tuple[ProfileConfig, ...] = () - - async def configure_component( - self, - client_id: str = "my_client_id", - client_secret: str = "my_client_secret", - profile_configs: tuple[ProfileConfig, ...] = (), - ) -> None: - """Configure the wihings component.""" - self._client_id = client_id - self._client_secret = client_secret - self._profile_configs = profile_configs - - hass_config = { - "homeassistant": { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - api.DOMAIN: {}, - const.DOMAIN: { - CONF_CLIENT_ID: self._client_id, - CONF_CLIENT_SECRET: self._client_secret, - const.CONF_USE_WEBHOOK: True, - }, - } - - await async_process_ha_core_config(self._hass, hass_config.get("homeassistant")) - assert await async_setup_component(self._hass, HA_DOMAIN, {}) - assert await async_setup_component(self._hass, webhook.DOMAIN, hass_config) - - assert await async_setup_component(self._hass, const.DOMAIN, hass_config) - await self._hass.async_block_till_done() - - @staticmethod - def _setup_api_method(api_method, value) -> None: - if isinstance(value, Exception): - api_method.side_effect = value - else: - api_method.return_value = value - - async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi: - """Set up a user profile through config flows.""" - profile_config = next( - iter( - [ - profile_config - for profile_config in self._profile_configs - if profile_config.user_id == user_id - ] - ) - ) - - api_mock: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) - api_mock.config_entry = MockConfigEntry( - domain=const.DOMAIN, - data={"profile": profile_config.profile}, - ) - ComponentFactory._setup_api_method( - api_mock.user_get_device, profile_config.api_response_user_get_device - ) - ComponentFactory._setup_api_method( - api_mock.sleep_get_summary, profile_config.api_response_sleep_get_summary - ) - ComponentFactory._setup_api_method( - api_mock.measure_get_meas, profile_config.api_response_measure_get_meas - ) - ComponentFactory._setup_api_method( - api_mock.notify_list, profile_config.api_response_notify_list - ) - ComponentFactory._setup_api_method( - api_mock.notify_revoke, profile_config.api_response_notify_revoke - ) - - self._api_class_mock.reset_mocks() - self._api_class_mock.return_value = api_mock - - # Get the withings config flow. - result = await self._hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": SOURCE_USER} - ) - assert result - - state = config_entry_oauth2_flow._encode_jwt( - self._hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - "https://account.withings.com/oauth2_user/authorize2?" - f"response_type=code&client_id={self._client_id}&" - "redirect_uri=https://example.com/auth/external/callback&" - f"state={state}" - "&scope=user.info,user.metrics,user.activity,user.sleepevents" - ) - - # Simulate user being redirected from withings site. - client: TestClient = await self._hass_client() - resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - self._aioclient_mock.clear_requests() - self._aioclient_mock.post( - "https://wbsapi.withings.net/v2/oauth2", - json={ - "body": { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "userid": profile_config.user_id, - }, - }, - ) - - # Present user with a list of profiles to choose from. - result = await self._hass.config_entries.flow.async_configure(result["flow_id"]) - assert result.get("type") == "form" - assert result.get("step_id") == "profile" - assert "profile" in result.get("data_schema").schema - - # Provide the user profile. - result = await self._hass.config_entries.flow.async_configure( - result["flow_id"], {const.PROFILE: profile_config.profile} - ) - - # Finish the config flow by calling it again. - assert result.get("type") == "create_entry" - assert result.get("result") - config_data = result.get("result").data - assert config_data.get(const.PROFILE) == profile_config.profile - assert config_data.get("auth_implementation") == const.DOMAIN - assert config_data.get("token") - - # Wait for remaining tasks to complete. - await self._hass.async_block_till_done() - - # Mock the webhook. - data_manager = get_data_manager_by_user_id(self._hass, user_id) - self._aioclient_mock.clear_requests() - self._aioclient_mock.request( - "HEAD", - data_manager.webhook_config.url, - ) - - return self._api_class_mock.return_value - - async def call_webhook(self, user_id: int, appli: NotifyAppli) -> WebhookResponse: - """Call the webhook to notify of data changes.""" - client: TestClient = await self._hass_client() - data_manager = get_data_manager_by_user_id(self._hass, user_id) - - resp = await client.post( - urlparse(data_manager.webhook_config.url).path, - data={"userid": user_id, "appli": appli.value}, - ) - - # Wait for remaining tasks to complete. - await self._hass.async_block_till_done() - - data = await resp.json() - resp.close() - - return WebhookResponse(message=data["message"], message_code=data["code"]) - - async def unload(self, profile: ProfileConfig) -> None: - """Unload the component for a specific user.""" - config_entries = get_config_entries_for_user_id(self._hass, profile.user_id) - - for config_entry in config_entries: - await config_entry.async_unload(self._hass) - - await self._hass.async_block_till_done() - - assert not get_data_manager_by_user_id(self._hass, profile.user_id) - - -def get_config_entries_for_user_id( - hass: HomeAssistant, user_id: int -) -> tuple[ConfigEntry]: - """Get a list of config entries that apply to a specific withings user.""" - return tuple( - config_entry - for config_entry in hass.config_entries.async_entries(const.DOMAIN) - if config_entry.data.get("token", {}).get("userid") == user_id - ) - - -def get_data_manager_by_user_id( - hass: HomeAssistant, user_id: int -) -> DataManager | None: - """Get a data manager by the user id.""" - return next( - iter( - [ - data_manager - for data_manager in get_all_data_managers(hass) - if data_manager.user_id == user_id - ] - ), - None, - ) - - -async def async_get_entity_id( - hass: HomeAssistant, - description: WithingsEntityDescription, - user_id: int, - platform: str, -) -> str | None: - """Get an entity id for a user's attribute.""" - entity_registry = er.async_get(hass) - unique_id = f"withings_{user_id}_{description.measurement.value}" - - return entity_registry.async_get_entity_id(platform, const.DOMAIN, unique_id) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index f1df0e3a65a..60125a35fed 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -20,10 +20,7 @@ from homeassistant.components.withings.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import ComponentFactory - from tests.common import MockConfigEntry, load_json_object_fixture -from tests.test_util.aiohttp import AiohttpClientMocker CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -38,22 +35,6 @@ USER_ID = 12345 WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" -@pytest.fixture -def component_factory( - hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, -): - """Return a factory for initializing the withings component.""" - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi" - ) as api_class_mock: - yield ComponentFactory( - hass, api_class_mock, hass_client_no_auth, aioclient_mock - ) - - @pytest.fixture(name="scopes") def mock_scopes() -> list[str]: """Fixture to set the scopes present in the OAuth token.""" @@ -78,8 +59,8 @@ def mock_expires_at() -> int: return time.time() + 3600 -@pytest.fixture(name="config_entry") -def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: +@pytest.fixture +def webhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: """Create Withings entry in Home Assistant.""" return MockConfigEntry( domain=DOMAIN, @@ -104,6 +85,32 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) +@pytest.fixture +def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=str(USER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": str(USER_ID), + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "webhook_id": WEBHOOK_ID, + }, + options={ + "use_webhook": False, + }, + ) + + @pytest.fixture(name="withings") def mock_withings(): """Mock withings.""" @@ -123,7 +130,7 @@ def mock_withings(): ) with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", + "homeassistant.components.withings.ConfigEntryWithingsApi", return_value=mock, ): yield mock @@ -135,7 +142,8 @@ def disable_webhook_delay(): mock = AsyncMock() with patch( - "homeassistant.components.withings.common.SUBSCRIBE_DELAY", timedelta(seconds=0) + "homeassistant.components.withings.common.SUBSCRIBE_DELAY", + timedelta(seconds=0), ), patch( "homeassistant.components.withings.common.UNSUBSCRIBE_DELAY", timedelta(seconds=0), diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index dca9fbc6437..8e641925d60 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,9 +1,11 @@ """Tests for the Withings component.""" from unittest.mock import AsyncMock +from aiohttp.client_exceptions import ClientResponseError +import pytest from withings_api.common import NotifyAppli -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from . import call_webhook, enable_webhooks, setup_integration @@ -17,18 +19,18 @@ async def test_binary_sensor( hass: HomeAssistant, withings: AsyncMock, disable_webhook_delay, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" await enable_webhooks(hass) - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id).state == STATE_UNKNOWN resp = await call_webhook( hass, @@ -49,3 +51,28 @@ async def test_binary_sensor( assert resp.message_code == 0 await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_polling_binary_sensor( + hass: HomeAssistant, + withings: AsyncMock, + disable_webhook_delay, + polling_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test binary sensor.""" + await setup_integration(hass, polling_config_entry) + + client = await hass_client_no_auth() + + entity_id = "binary_sensor.henk_in_bed" + + assert hass.states.get(entity_id) is None + + with pytest.raises(ClientResponseError): + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, + ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index d5745ae9bed..1fc26824d45 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -83,12 +83,12 @@ async def test_config_non_unique_profile( hass_client_no_auth: ClientSessionGenerator, current_request_with_host: None, withings: AsyncMock, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -136,21 +136,21 @@ async def test_config_reauth_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth an existing profile reauthenticates the config entry.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, - "entry_id": config_entry.entry_id, + "entry_id": polling_config_entry.entry_id, }, - data=config_entry.data, + data=polling_config_entry.data, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" @@ -199,21 +199,21 @@ async def test_config_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth with wrong account.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, - "entry_id": config_entry.entry_id, + "entry_id": polling_config_entry.entry_id, }, - data=config_entry.data, + data=polling_config_entry.data, ) assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" @@ -262,15 +262,15 @@ async def test_options_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: """Test options flow.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init(polling_config_entry.entry_id) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 15f0fff808d..bae6df37126 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -4,18 +4,20 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from urllib.parse import urlparse +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -from withings_api.common import NotifyAppli +from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException +from homeassistant import config_entries from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import enable_webhooks, setup_integration -from .conftest import WEBHOOK_ID +from . import call_webhook, enable_webhooks, setup_integration +from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -106,12 +108,12 @@ async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, disable_webhook_delay, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" await enable_webhooks(hass) - await setup_integration(hass, config_entry) + await setup_integration(hass, webhook_config_entry) await hass_client_no_auth() await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) @@ -132,6 +134,27 @@ async def test_data_manager_webhook_subscription( withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) +async def test_webhook_subscription_polling_config( + hass: HomeAssistant, + withings: AsyncMock, + disable_webhook_delay, + polling_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test webhook subscriptions not run when polling.""" + await setup_integration(hass, polling_config_entry) + await hass_client_no_auth() + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert withings.notify_revoke.call_count == 0 + assert withings.notify_subscribe.call_count == 0 + assert withings.notify_list.call_count == 0 + + @pytest.mark.parametrize( "method", [ @@ -142,13 +165,14 @@ async def test_data_manager_webhook_subscription( async def test_requests( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, method: str, disable_webhook_delay, ) -> None: """Test we handle request methods Withings sends.""" - await setup_integration(hass, config_entry) + await enable_webhooks(hass) + await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) @@ -159,6 +183,59 @@ async def test_requests( assert response.status == 200 +async def test_webhooks_request_data( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + disable_webhook_delay, +) -> None: + """Test calling a webhook requests data.""" + await enable_webhooks(hass) + await setup_integration(hass, webhook_config_entry) + client = await hass_client_no_auth() + + assert withings.async_measure_get_meas.call_count == 1 + + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) + assert withings.async_measure_get_meas.call_count == 2 + + +@pytest.mark.parametrize( + "error", + [ + UnauthorizedException(401), + AuthFailedException(500), + ], +) +async def test_triggering_reauth( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + error: Exception, +) -> None: + """Test triggering reauth.""" + await setup_integration(hass, polling_config_entry) + + withings.async_measure_get_meas.side_effect = error + future = dt_util.utcnow() + timedelta(minutes=10) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == config_entries.SOURCE_REAUTH + + @pytest.mark.parametrize( ("config_entry"), [ @@ -220,7 +297,7 @@ async def test_config_flow_upgrade( async def test_webhook_post( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, disable_webhook_delay, body: dict[str, Any], @@ -228,7 +305,8 @@ async def test_webhook_post( current_request_with_host: None, ) -> None: """Test webhook callback.""" - await setup_integration(hass, config_entry) + await enable_webhooks(hass) + await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index cf0069c968a..b0df6e4c3c2 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,24 +1,26 @@ """Tests for the Withings component.""" +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from withings_api.common import NotifyAppli from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.const import Measurement +from homeassistant.components.withings.const import DOMAIN, Measurement from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.components.withings.sensor import SENSORS +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from . import call_webhook, setup_integration -from .common import async_get_entity_id +from . import call_webhook, enable_webhooks, setup_integration from .conftest import USER_ID, WEBHOOK_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { @@ -60,6 +62,19 @@ EXPECTED_DATA = ( ) +async def async_get_entity_id( + hass: HomeAssistant, + description: WithingsEntityDescription, + user_id: int, + platform: str, +) -> str | None: + """Get an entity id for a user's attribute.""" + entity_registry = er.async_get(hass) + unique_id = f"withings_{user_id}_{description.measurement.value}" + + return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + + def async_assert_state_equals( entity_id: str, state_obj: State, @@ -79,12 +94,13 @@ def async_assert_state_equals( async def test_sensor_default_enabled_entities( hass: HomeAssistant, withings: AsyncMock, - config_entry: MockConfigEntry, + webhook_config_entry: MockConfigEntry, disable_webhook_delay, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" - await setup_integration(hass, config_entry) + await enable_webhooks(hass) + await setup_integration(hass, webhook_config_entry) entity_registry: EntityRegistry = er.async_get(hass) client = await hass_client_no_auth() @@ -122,11 +138,31 @@ async def test_all_entities( snapshot: SnapshotAssertion, withings: AsyncMock, disable_webhook_delay, - config_entry: MockConfigEntry, + polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - await setup_integration(hass, config_entry) + await setup_integration(hass, polling_config_entry) for sensor in SENSORS: entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) assert hass.states.get(entity_id) == snapshot + + +async def test_update_failed( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test all entities.""" + await setup_integration(hass, polling_config_entry) + + withings.async_measure_get_meas.side_effect = Exception + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.henk_weight") + assert state is not None + assert state.state == STATE_UNAVAILABLE From 249e20f8e59099eee17093c926d77795562cd767 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 26 Sep 2023 10:57:13 +0200 Subject: [PATCH 803/984] Bump pyduotecno to 2023.9.0 (#100900) --- homeassistant/components/duotecno/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index be2a74f884f..d04e883f867 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", - "requirements": ["pyDuotecno==2023.8.4"] + "requirements": ["pyDuotecno==2023.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 738794f5a84..12dbc47da0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1535,7 +1535,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.8.4 +pyDuotecno==2023.9.0 # homeassistant.components.eight_sleep pyEight==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c93ff021492..47de6364bbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1168,7 +1168,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.8.4 +pyDuotecno==2023.9.0 # homeassistant.components.eight_sleep pyEight==0.3.2 From b0a7e68984289e0c78ea38db205502a50330d932 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 10:59:45 +0200 Subject: [PATCH 804/984] Rename Withings coordinator file (#100899) Rename common.py to coordinator.py --- homeassistant/components/withings/__init__.py | 2 +- homeassistant/components/withings/binary_sensor.py | 2 +- .../components/withings/{common.py => coordinator.py} | 0 homeassistant/components/withings/entity.py | 2 +- homeassistant/components/withings/sensor.py | 2 +- tests/components/withings/conftest.py | 4 ++-- 6 files changed, 6 insertions(+), 6 deletions(-) rename homeassistant/components/withings/{common.py => coordinator.py} (100%) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 1c66115d9b5..2e9ff462936 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -36,8 +36,8 @@ from homeassistant.helpers.typing import ConfigType from . import const from .api import ConfigEntryWithingsApi -from .common import WithingsDataUpdateCoordinator from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER +from .coordinator import WithingsDataUpdateCoordinator DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index e12a0929c2a..a6e19d3ef86 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -14,8 +14,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import WithingsDataUpdateCoordinator from .const import DOMAIN, Measurement +from .coordinator import WithingsDataUpdateCoordinator from .entity import WithingsEntity, WithingsEntityDescription diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/coordinator.py similarity index 100% rename from homeassistant/components/withings/common.py rename to homeassistant/components/withings/coordinator.py diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index 855162c4616..8005f97bfaa 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -9,8 +9,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .common import WithingsDataUpdateCoordinator from .const import DOMAIN, Measurement +from .coordinator import WithingsDataUpdateCoordinator @dataclass diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 7b867ad0cff..42f5ac18f2f 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -23,7 +23,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import WithingsDataUpdateCoordinator from .const import ( DOMAIN, SCORE_POINTS, @@ -33,6 +32,7 @@ from .const import ( UOM_MMHG, Measurement, ) +from .coordinator import WithingsDataUpdateCoordinator from .entity import WithingsEntity, WithingsEntityDescription diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 60125a35fed..e7777d470a5 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -142,10 +142,10 @@ def disable_webhook_delay(): mock = AsyncMock() with patch( - "homeassistant.components.withings.common.SUBSCRIBE_DELAY", + "homeassistant.components.withings.coordinator.SUBSCRIBE_DELAY", timedelta(seconds=0), ), patch( - "homeassistant.components.withings.common.UNSUBSCRIBE_DELAY", + "homeassistant.components.withings.coordinator.UNSUBSCRIBE_DELAY", timedelta(seconds=0), ): yield mock From 1e76d37ceea23e63a495fec4eb43fcb0189fe989 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Sep 2023 11:35:51 +0200 Subject: [PATCH 805/984] Rename PipelineData.pipeline_runs to pipeline_debug (#100907) --- .../components/assist_pipeline/pipeline.py | 12 +++--- .../assist_pipeline/websocket_api.py | 14 +++---- .../assist_pipeline/test_websocket.py | 40 +++++++++---------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 89bb9736737..f7b9ee7e3d1 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -499,11 +499,11 @@ class PipelineRun: raise InvalidPipelineStagesError(self.start_stage, self.end_stage) pipeline_data: PipelineData = self.hass.data[DOMAIN] - if self.pipeline.id not in pipeline_data.pipeline_runs: - pipeline_data.pipeline_runs[self.pipeline.id] = LimitedSizeDict( + if self.pipeline.id not in pipeline_data.pipeline_debug: + pipeline_data.pipeline_debug[self.pipeline.id] = LimitedSizeDict( size_limit=STORED_PIPELINE_RUNS ) - pipeline_data.pipeline_runs[self.pipeline.id][self.id] = PipelineRunDebug() + pipeline_data.pipeline_debug[self.pipeline.id][self.id] = PipelineRunDebug() # Initialize with audio settings self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES) @@ -518,10 +518,10 @@ class PipelineRun: """Log an event and call listener.""" self.event_callback(event) pipeline_data: PipelineData = self.hass.data[DOMAIN] - if self.id not in pipeline_data.pipeline_runs[self.pipeline.id]: + if self.id not in pipeline_data.pipeline_debug[self.pipeline.id]: # This run has been evicted from the logged pipeline runs already return - pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event) + pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event) def start(self, device_id: str | None) -> None: """Emit run start event.""" @@ -1559,7 +1559,7 @@ class PipelineStorageCollectionWebsocket( class PipelineData: """Store and debug data stored in hass.data.""" - pipeline_runs: dict[str, LimitedSizeDict[str, PipelineRunDebug]] + pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] pipeline_store: PipelineStorageCollection pipeline_devices: set[str] = field(default_factory=set, init=False) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index bc542b5c32b..f57424223cf 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -258,18 +258,18 @@ def websocket_list_runs( pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_id = msg["pipeline_id"] - if pipeline_id not in pipeline_data.pipeline_runs: + if pipeline_id not in pipeline_data.pipeline_debug: connection.send_result(msg["id"], {"pipeline_runs": []}) return - pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + pipeline_debug = pipeline_data.pipeline_debug[pipeline_id] connection.send_result( msg["id"], { "pipeline_runs": [ {"pipeline_run_id": id, "timestamp": pipeline_run.timestamp} - for id, pipeline_run in pipeline_runs.items() + for id, pipeline_run in pipeline_debug.items() ] }, ) @@ -294,7 +294,7 @@ def websocket_get_run( pipeline_id = msg["pipeline_id"] pipeline_run_id = msg["pipeline_run_id"] - if pipeline_id not in pipeline_data.pipeline_runs: + if pipeline_id not in pipeline_data.pipeline_debug: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, @@ -302,9 +302,9 @@ def websocket_get_run( ) return - pipeline_runs = pipeline_data.pipeline_runs[pipeline_id] + pipeline_debug = pipeline_data.pipeline_debug[pipeline_id] - if pipeline_run_id not in pipeline_runs: + if pipeline_run_id not in pipeline_debug: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_FOUND, @@ -314,7 +314,7 @@ def websocket_get_run( connection.send_result( msg["id"], - {"events": pipeline_runs[pipeline_run_id].events}, + {"events": pipeline_debug[pipeline_run_id].events}, ) diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 76ec88b009b..f995a0d3577 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -62,8 +62,8 @@ async def test_text_only_pipeline( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -153,8 +153,8 @@ async def test_audio_pipeline( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -316,8 +316,8 @@ async def test_audio_pipeline_with_wake_word_no_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -452,8 +452,8 @@ async def test_intent_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -505,8 +505,8 @@ async def test_text_pipeline_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -573,8 +573,8 @@ async def test_intent_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -628,8 +628,8 @@ async def test_audio_pipeline_timeout( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -760,8 +760,8 @@ async def test_stt_stream_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -828,8 +828,8 @@ async def test_tts_failed( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { @@ -1792,8 +1792,8 @@ async def test_audio_pipeline_with_enhancements( events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_runs)[0] - pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + pipeline_id = list(pipeline_data.pipeline_debug)[0] + pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] await client.send_json_auto_id( { From f21d924dd5cef56e3e1349c7978a7004b8a4acc1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 11:41:40 +0200 Subject: [PATCH 806/984] Add entity translations to Wallbox (#99021) Co-authored-by: Robert Resch --- homeassistant/components/wallbox/__init__.py | 4 +- homeassistant/components/wallbox/lock.py | 6 +- homeassistant/components/wallbox/number.py | 3 +- homeassistant/components/wallbox/sensor.py | 30 +++++----- homeassistant/components/wallbox/strings.json | 58 +++++++++++++++++++ homeassistant/components/wallbox/switch.py | 6 +- tests/components/wallbox/const.py | 12 ++-- tests/components/wallbox/test_sensor.py | 4 +- 8 files changed, 88 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 9b27b9c4bd1..4db217d0a54 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -246,6 +246,8 @@ class InvalidAuth(HomeAssistantError): class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): """Defines a base Wallbox entity.""" + _attr_has_entity_name = True + @property def device_info(self) -> DeviceInfo: """Return device information about this Wallbox device.""" @@ -256,7 +258,7 @@ class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY], ) }, - name=f"Wallbox - {self.coordinator.data[CHARGER_NAME_KEY]}", + name=f"Wallbox {self.coordinator.data[CHARGER_NAME_KEY]}", manufacturer="Wallbox", model=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], sw_version=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SOFTWARE_KEY][ diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7b5dca58010..04a587ae34d 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -20,7 +20,7 @@ from .const import ( LOCK_TYPES: dict[str, LockEntityDescription] = { CHARGER_LOCKED_UNLOCKED_KEY: LockEntityDescription( key=CHARGER_LOCKED_UNLOCKED_KEY, - name="Locked/Unlocked", + translation_key="lock", ), } @@ -42,7 +42,7 @@ async def async_setup_entry( async_add_entities( [ - WallboxLock(coordinator, entry, description) + WallboxLock(coordinator, description) for ent in coordinator.data if (description := LOCK_TYPES.get(ent)) ] @@ -55,14 +55,12 @@ class WallboxLock(WallboxEntity, LockEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: LockEntityDescription, ) -> None: """Initialize a Wallbox lock.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 58d4a5e6afb..b8ce331146d 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -33,7 +33,7 @@ class WallboxNumberEntityDescription(NumberEntityDescription): NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, - name="Max. Charging Current", + translation_key="maximum_charging_current", ), } @@ -77,7 +77,6 @@ class WallboxNumber(WallboxEntity, NumberEntity): super().__init__(coordinator) self.entity_description = description self._coordinator = coordinator - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" self._is_bidirectional = ( coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:3] diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index afd2b13f790..56d9e0be735 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -60,7 +60,7 @@ class WallboxSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { CHARGER_CHARGING_POWER_KEY: WallboxSensorEntityDescription( key=CHARGER_CHARGING_POWER_KEY, - name="Charging Power", + translation_key=CHARGER_CHARGING_POWER_KEY, precision=2, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, @@ -68,7 +68,7 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_MAX_AVAILABLE_POWER_KEY: WallboxSensorEntityDescription( key=CHARGER_MAX_AVAILABLE_POWER_KEY, - name="Max Available Power", + translation_key=CHARGER_MAX_AVAILABLE_POWER_KEY, precision=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -76,15 +76,15 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_CHARGING_SPEED_KEY: WallboxSensorEntityDescription( key=CHARGER_CHARGING_SPEED_KEY, + translation_key=CHARGER_CHARGING_SPEED_KEY, icon="mdi:speedometer", - name="Charging Speed", precision=0, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_ADDED_RANGE_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_RANGE_KEY, + translation_key=CHARGER_ADDED_RANGE_KEY, icon="mdi:map-marker-distance", - name="Added Range", precision=0, native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, @@ -92,7 +92,7 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_ADDED_ENERGY_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_ENERGY_KEY, - name="Added Energy", + translation_key=CHARGER_ADDED_ENERGY_KEY, precision=2, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -100,7 +100,7 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_ADDED_DISCHARGED_ENERGY_KEY: WallboxSensorEntityDescription( key=CHARGER_ADDED_DISCHARGED_ENERGY_KEY, - name="Discharged Energy", + translation_key=CHARGER_ADDED_DISCHARGED_ENERGY_KEY, precision=2, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -108,44 +108,44 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { ), CHARGER_COST_KEY: WallboxSensorEntityDescription( key=CHARGER_COST_KEY, + translation_key=CHARGER_COST_KEY, icon="mdi:ev-station", - name="Cost", state_class=SensorStateClass.TOTAL_INCREASING, ), CHARGER_STATE_OF_CHARGE_KEY: WallboxSensorEntityDescription( key=CHARGER_STATE_OF_CHARGE_KEY, - name="State of Charge", + translation_key=CHARGER_STATE_OF_CHARGE_KEY, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_CURRENT_MODE_KEY: WallboxSensorEntityDescription( key=CHARGER_CURRENT_MODE_KEY, + translation_key=CHARGER_CURRENT_MODE_KEY, icon="mdi:ev-station", - name="Current Mode", ), CHARGER_DEPOT_PRICE_KEY: WallboxSensorEntityDescription( key=CHARGER_DEPOT_PRICE_KEY, + translation_key=CHARGER_DEPOT_PRICE_KEY, icon="mdi:ev-station", - name="Depot Price", precision=2, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_ENERGY_PRICE_KEY: WallboxSensorEntityDescription( key=CHARGER_ENERGY_PRICE_KEY, + translation_key=CHARGER_ENERGY_PRICE_KEY, icon="mdi:ev-station", - name="Energy Price", precision=2, state_class=SensorStateClass.MEASUREMENT, ), CHARGER_STATUS_DESCRIPTION_KEY: WallboxSensorEntityDescription( key=CHARGER_STATUS_DESCRIPTION_KEY, + translation_key=CHARGER_STATUS_DESCRIPTION_KEY, icon="mdi:ev-station", - name="Status Description", ), CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxSensorEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, - name="Max. Charging Current", + translation_key=CHARGER_MAX_CHARGING_CURRENT_KEY, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -161,7 +161,7 @@ async def async_setup_entry( async_add_entities( [ - WallboxSensor(coordinator, entry, description) + WallboxSensor(coordinator, description) for ent in coordinator.data if (description := SENSOR_TYPES.get(ent)) ] @@ -176,13 +176,11 @@ class WallboxSensor(WallboxEntity, SensorEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: WallboxSensorEntityDescription, ) -> None: """Initialize a Wallbox sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 4cde9c6d255..69db4bb97e3 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -25,5 +25,63 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, + "number": { + "maximum_charging_current": { + "name": "Maximum charging current" + } + }, + "sensor": { + "charging_power": { + "name": "Charging power" + }, + "max_available_power": { + "name": "Max available power" + }, + "charging_speed": { + "name": "Charging speed" + }, + "added_range": { + "name": "Added range" + }, + "added_energy": { + "name": "Added energy" + }, + "added_discharged_energy": { + "name": "Discharged energy" + }, + "cost": { + "name": "Cost" + }, + "state_of_charge": { + "name": "State of charge" + }, + "current_mode": { + "name": "Current mode" + }, + "depot_price": { + "name": "Depot price" + }, + "energy_price": { + "name": "Energy price" + }, + "status_description": { + "name": "Status description" + }, + "max_charging_current": { + "name": "Max charging current" + } + }, + "switch": { + "pause_resume": { + "name": "Pause/resume" + } + } } } diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 7a0736f59e7..b101ffe1c09 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -21,7 +21,7 @@ from .const import ( SWITCH_TYPES: dict[str, SwitchEntityDescription] = { CHARGER_PAUSE_RESUME_KEY: SwitchEntityDescription( key=CHARGER_PAUSE_RESUME_KEY, - name="Pause/Resume", + translation_key="pause_resume", ), } @@ -32,7 +32,7 @@ async def async_setup_entry( """Create wallbox sensor entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [WallboxSwitch(coordinator, entry, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])] + [WallboxSwitch(coordinator, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])] ) @@ -42,13 +42,11 @@ class WallboxSwitch(WallboxEntity, SwitchEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, description: SwitchEntityDescription, ) -> None: """Initialize a Wallbox switch.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{entry.title} {description.name}" self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 1f052643696..477fb10d292 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -5,9 +5,9 @@ TTL = "ttl" ERROR = "error" STATUS = "status" -MOCK_NUMBER_ENTITY_ID = "number.mock_title_max_charging_current" -MOCK_LOCK_ENTITY_ID = "lock.mock_title_locked_unlocked" -MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.mock_title_charging_speed" -MOCK_SENSOR_CHARGING_POWER_ID = "sensor.mock_title_charging_power" -MOCK_SENSOR_MAX_AVAILABLE_POWER = "sensor.mock_title_max_available_power" -MOCK_SWITCH_ENTITY_ID = "switch.mock_title_pause_resume" +MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" +MOCK_LOCK_ENTITY_ID = "lock.wallbox_wallboxname_lock" +MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" +MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" +MOCK_SENSOR_MAX_AVAILABLE_POWER = "sensor.wallbox_wallboxname_max_available_power" +MOCK_SWITCH_ENTITY_ID = "switch.wallbox_wallboxname_pause_resume" diff --git a/tests/components/wallbox/test_sensor.py b/tests/components/wallbox/test_sensor.py index a6bda688997..ca12e1d9ac3 100644 --- a/tests/components/wallbox/test_sensor.py +++ b/tests/components/wallbox/test_sensor.py @@ -21,11 +21,11 @@ async def test_wallbox_sensor_class( state = hass.states.get(MOCK_SENSOR_CHARGING_POWER_ID) assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == UnitOfPower.KILO_WATT - assert state.name == "Mock Title Charging Power" + assert state.name == "Wallbox WallboxName Charging power" state = hass.states.get(MOCK_SENSOR_CHARGING_SPEED_ID) assert state.attributes[CONF_ICON] == "mdi:speedometer" - assert state.name == "Mock Title Charging Speed" + assert state.name == "Wallbox WallboxName Charging speed" # Test round with precision '0' works state = hass.states.get(MOCK_SENSOR_MAX_AVAILABLE_POWER) From f837e6722cdc111e4931463b8d9f01b37581162b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 26 Sep 2023 12:27:56 +0200 Subject: [PATCH 807/984] Update cryptography to 41.0.4 (#100911) --- 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 e6c019092f7..b8cdd204279 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.12.0 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.3 +cryptography==41.0.4 dbus-fast==2.10.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 diff --git a/pyproject.toml b/pyproject.toml index 7dfd584c598..38a1a6d0b8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "lru-dict==1.2.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.3", + "cryptography==41.0.4", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", "orjson==3.9.7", diff --git a/requirements.txt b/requirements.txt index 40f7584ca31..111096db59f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 PyJWT==2.8.0 -cryptography==41.0.3 +cryptography==41.0.4 pyOpenSSL==23.2.0 orjson==3.9.7 packaging>=23.1 From 667f4b1ca83bd08d6777a44104abd9c693607739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Sep 2023 05:29:46 -0500 Subject: [PATCH 808/984] Mark Bluetooth scanner as not scanning when watchdog timeout is reached (#100738) --- .../components/bluetooth/base_scanner.py | 4 + homeassistant/components/bluetooth/scanner.py | 3 + .../components/bluetooth/test_base_scanner.py | 81 +++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 455619182ab..240610e4868 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -131,6 +131,9 @@ class BaseHaScanner(ABC): self.name, SCANNER_WATCHDOG_TIMEOUT, ) + self.scanning = False + return + self.scanning = not self._connecting @contextmanager def connecting(self) -> Generator[None, None, None]: @@ -302,6 +305,7 @@ class BaseHaRemoteScanner(BaseHaScanner): 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] diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index eb3ce11b644..896d9dc7958 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -329,6 +329,9 @@ class HaScanner(BaseHaScanner): 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: diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 5662bc6324b..fc870f2bfe3 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -23,6 +23,8 @@ from homeassistant.components.bluetooth.advertisement_tracker import ( from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) from homeassistant.core import HomeAssistant, callback @@ -557,3 +559,82 @@ async def test_device_with_ten_minute_advertising_interval( cancel() unsetup() + + +async def test_scanner_stops_responding( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_bluetooth: None +) -> None: + """Test we mark a scanner are not scanning when it stops responding.""" + manager = _get_manager() + + class FakeScanner(BaseHaRemoteScanner): + """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), + ) + scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + unsetup = scanner.async_setup() + cancel = manager.async_register_scanner(scanner, True) + + start_time_monotonic = time.monotonic() + + assert scanner.scanning is True + failure_reached_time = ( + start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds() + ) + # 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", + return_value=failure_reached_time, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() + + assert scanner.scanning is False + + bparasite_device = generate_ble_device( + "44:44:33:11:23:45", + "bparasite", + {}, + rssi=-100, + ) + bparasite_device_adv = generate_advertisement_data( + local_name="bparasite", + service_uuids=[], + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + + failure_reached_time += 1 + + with patch( + "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", + return_value=failure_reached_time, + ): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + + # As soon as we get a detection, we know the scanner is working again + assert scanner.scanning is True + + cancel() + unsetup() From e5a151c4c3ca3f36a28b7281f335b140d69aea13 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 26 Sep 2023 12:33:39 +0200 Subject: [PATCH 809/984] Add state classes to Tomorrowio sensors (#100692) --- homeassistant/components/tomorrowio/sensor.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index cd48af8536a..4aa2748ad30 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -115,6 +115,7 @@ SENSOR_TYPES = ( name="Feels Like", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="dew_point", @@ -123,6 +124,7 @@ SENSOR_TYPES = ( icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as hPa TomorrowioSensorEntityDescription( @@ -131,6 +133,7 @@ SENSOR_TYPES = ( name="Pressure (Surface Level)", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as W/m^2, convert to BTUs/(hr * ft^2) for imperial # https://www.theunitconverter.com/watt-square-meter-to-btu-hour-square-foot-conversion/ @@ -142,6 +145,7 @@ SENSOR_TYPES = ( unit_metric=UnitOfIrradiance.WATTS_PER_SQUARE_METER, imperial_conversion=(1 / 3.15459), device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( @@ -151,6 +155,8 @@ SENSOR_TYPES = ( icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: DistanceConverter.convert( val, UnitOfLength.KILOMETERS, @@ -165,6 +171,8 @@ SENSOR_TYPES = ( icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: DistanceConverter.convert( val, UnitOfLength.KILOMETERS, @@ -186,6 +194,8 @@ SENSOR_TYPES = ( icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, unit_metric=UnitOfSpeed.METERS_PER_SECOND, + device_class=SensorDeviceClass.SPEED, + state_class=SensorStateClass.MEASUREMENT, imperial_conversion=lambda val: SpeedConverter.convert( val, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.MILES_PER_HOUR ), @@ -207,6 +217,7 @@ SENSOR_TYPES = ( native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="particulate_matter_2_5_mm", @@ -214,6 +225,7 @@ SENSOR_TYPES = ( name="Particulate Matter < 2.5 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="particulate_matter_10_mm", @@ -221,6 +233,7 @@ SENSOR_TYPES = ( name="Particulate Matter < 10 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Nitrogen Dioxide is 46.01 @@ -231,6 +244,7 @@ SENSOR_TYPES = ( native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(46.01), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as ppb, convert to ppm TomorrowioSensorEntityDescription( @@ -240,6 +254,7 @@ SENSOR_TYPES = ( native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, multiplication_factor=1 / 1000, device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, ), # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Sulphur Dioxide is 64.07 @@ -250,12 +265,14 @@ SENSOR_TYPES = ( native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(64.07), device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="us_epa_air_quality_index", attribute=TMRW_ATTR_EPA_AQI, name="US EPA Air Quality Index", device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="us_epa_primary_pollutant", From 4df14b26255527e12e6ae98be0204e4e0b24f5e8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Sep 2023 12:38:25 +0200 Subject: [PATCH 810/984] Remove duplicated call to `PipelineRun.end` from `PipelineInput.execute` (#100909) Remove duplicated call to PipelineRun.end from PipelineInput.execute --- homeassistant/components/assist_pipeline/pipeline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index f7b9ee7e3d1..b047a09912a 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1228,7 +1228,6 @@ class PipelineInput: ) if detect_result is None: # No wake word. Abort the rest of the pipeline. - await self.run.end() return current_stage = PipelineStage.STT From bd7a86a0a689bb6d649dab033d51c1a0216fbd5a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 26 Sep 2023 12:57:10 +0200 Subject: [PATCH 811/984] Remove async-timeout as core dependency (#100912) --- .../homeassistant_hardware/silabs_multiprotocol_addon.py | 3 +-- homeassistant/components/rainbird/coordinator.py | 3 +-- homeassistant/package_constraints.txt | 1 - pyproject.toml | 1 - requirements.txt | 1 - 5 files changed, 2 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index b4723a88742..c04575d8005 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,7 +8,6 @@ import dataclasses import logging from typing import Any, Protocol -import async_timeout import voluptuous as vol import yarl @@ -74,7 +73,7 @@ class WaitingAddonManager(AddonManager): async def async_wait_until_addon_state(self, *states: AddonState) -> None: """Poll an addon's info until it is in a specific state.""" - async with async_timeout.timeout(ADDON_INFO_POLL_TIMEOUT): + async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT): while True: try: info = await self.async_get_addon_info() diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index d61f9140771..5c40ef808b2 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -9,7 +9,6 @@ from functools import cached_property import logging from typing import TypeVar -import async_timeout from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, @@ -140,7 +139,7 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): async def _async_update_data(self) -> Schedule: """Fetch data from Rain Bird device.""" try: - async with async_timeout.timeout(TIMEOUT_SECONDS): + async with asyncio.timeout(TIMEOUT_SECONDS): return await self._controller.get_schedule() except RainbirdApiException as err: raise UpdateFailed(f"Error communicating with Device: {err}") from err diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b8cdd204279..34e6a612789 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,6 @@ aiodiscover==1.5.1 aiohttp==3.8.5 aiohttp_cors==0.7.0 astral==2.2 -async-timeout==4.0.3 async-upnp-client==0.36.0 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 diff --git a/pyproject.toml b/pyproject.toml index 38a1a6d0b8e..e4d3876d9f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ requires-python = ">=3.11.0" dependencies = [ "aiohttp==3.8.5", "astral==2.2", - "async-timeout==4.0.3", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==23.8.0", diff --git a/requirements.txt b/requirements.txt index 111096db59f..60eb2359ba5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ # Home Assistant Core aiohttp==3.8.5 astral==2.2 -async-timeout==4.0.3 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 awesomeversion==23.8.0 From 4ffac3e7ed67948867c7e9863f688f427483f905 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 13:16:37 +0200 Subject: [PATCH 812/984] Cleanup Withings const import (#100914) --- homeassistant/components/withings/__init__.py | 14 ++++++-------- tests/components/withings/test_init.py | 17 +++++++++-------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 2e9ff462936..47c90e2b4d1 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -34,18 +34,16 @@ from homeassistant.helpers import config_entry_oauth2_flow, config_validation as from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType -from . import const from .api import ConfigEntryWithingsApi -from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER +from .const import CONF_PROFILES, CONF_USE_WEBHOOK, CONFIG, DOMAIN, LOGGER from .coordinator import WithingsDataUpdateCoordinator -DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - cv.deprecated(const.CONF_PROFILES), + cv.deprecated(CONF_PROFILES), cv.deprecated(CONF_CLIENT_ID), cv.deprecated(CONF_CLIENT_SECRET), vol.Schema( @@ -54,8 +52,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_CLIENT_SECRET): vol.All( cv.string, vol.Length(min=1) ), - vol.Optional(const.CONF_USE_WEBHOOK): cv.boolean, - vol.Optional(const.CONF_PROFILES): vol.All( + vol.Optional(CONF_USE_WEBHOOK): cv.boolean, + vol.Optional(CONF_PROFILES): vol.All( cv.ensure_list, vol.Unique(), vol.Length(min=1), @@ -74,10 +72,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not (conf := config.get(DOMAIN)): # Apply the defaults. conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] - hass.data[DOMAIN] = {const.CONFIG: conf} + hass.data[DOMAIN] = {CONFIG: conf} return True - hass.data[DOMAIN] = {const.CONFIG: conf} + hass.data[DOMAIN] = {CONFIG: conf} # Setup the oauth2 config flow. if CONF_CLIENT_ID in conf: diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index bae6df37126..6e5c10390ff 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -11,7 +11,8 @@ from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedEx from homeassistant import config_entries from homeassistant.components.webhook import async_generate_url -from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const +from homeassistant.components.withings import CONFIG_SCHEMA, async_setup +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -25,7 +26,7 @@ from tests.typing import ClientSessionGenerator def config_schema_validate(withings_config) -> dict: """Assert a schema config succeeds.""" - hass_config = {const.DOMAIN: withings_config} + hass_config = {DOMAIN: withings_config} return CONFIG_SCHEMA(hass_config) @@ -42,7 +43,7 @@ def test_config_schema_basic_config() -> None: { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: True, + CONF_USE_WEBHOOK: True, } ) @@ -74,23 +75,23 @@ def test_config_schema_use_webhook() -> None: { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: True, + CONF_USE_WEBHOOK: True, } ) - assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is True + assert config[DOMAIN][CONF_USE_WEBHOOK] is True config = config_schema_validate( { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, + CONF_USE_WEBHOOK: False, } ) - assert config[const.DOMAIN][const.CONF_USE_WEBHOOK] is False + assert config[DOMAIN][CONF_USE_WEBHOOK] is False config_schema_assert_fail( { CONF_CLIENT_ID: "my_client_id", CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: "A", + CONF_USE_WEBHOOK: "A", } ) From 2165f0a538f96bdb51f616e49fe99d0b6b8b2618 Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Tue, 26 Sep 2023 13:58:58 +0200 Subject: [PATCH 813/984] Add missing input_button service translation (#100387) --- homeassistant/components/input_button/services.yaml | 2 ++ homeassistant/components/input_button/strings.json | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/input_button/services.yaml b/homeassistant/components/input_button/services.yaml index 7c57fcff272..8e737ac7055 100644 --- a/homeassistant/components/input_button/services.yaml +++ b/homeassistant/components/input_button/services.yaml @@ -2,3 +2,5 @@ press: target: entity: domain: input_button + +reload: diff --git a/homeassistant/components/input_button/strings.json b/homeassistant/components/input_button/strings.json index b51d04926f5..d36871917a9 100644 --- a/homeassistant/components/input_button/strings.json +++ b/homeassistant/components/input_button/strings.json @@ -18,6 +18,10 @@ "press": { "name": "Press", "description": "Mimics the physical button press on the device." + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." } } } From 29da43b9a987a3425a5b3798e3ee8ff1ae1689f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 26 Sep 2023 14:04:50 +0200 Subject: [PATCH 814/984] Bump aioairzone-cloud to 0.2.2 (#100915) 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 289565f0473..63d9d3fffaa 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.2.1"] + "requirements": ["aioairzone-cloud==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 12dbc47da0e..7ec29468154 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.1 +aioairzone-cloud==0.2.2 # homeassistant.components.airzone aioairzone==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47de6364bbc..773f4fde707 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.1 +aioairzone-cloud==0.2.2 # homeassistant.components.airzone aioairzone==0.6.8 From 73cfe659ea13e4d4ac13a7f8a0f9b1b26f38ba79 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Tue, 26 Sep 2023 08:34:43 -0400 Subject: [PATCH 815/984] Make Hydrawise compliant with new naming standards (#100921) * Make Hydrawise compliant with new naming standards * Update binary_sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/hydrawise/binary_sensor.py | 3 +-- homeassistant/components/hydrawise/entity.py | 1 + homeassistant/components/hydrawise/sensor.py | 4 ++-- .../components/hydrawise/strings.json | 23 +++++++++++++++++++ homeassistant/components/hydrawise/switch.py | 4 ++-- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 8a5d6fe2f83..1c40b16926d 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -23,14 +23,13 @@ from .entity import HydrawiseEntity BINARY_SENSOR_STATUS = BinarySensorEntityDescription( key="status", - name="Status", device_class=BinarySensorDeviceClass.CONNECTIVITY, ) BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="is_watering", - name="Watering", + translation_key="watering", device_class=BinarySensorDeviceClass.MOISTURE, ), ) diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index db07faef6d0..c3f295e1c4d 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -15,6 +15,7 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): """Entity class for Hydrawise devices.""" _attr_attribution = "Data provided by hydrawise.com" + _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index bcf178744c8..a5bd9251a33 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -24,12 +24,12 @@ from .entity import HydrawiseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="next_cycle", - name="Next Cycle", + translation_key="next_cycle", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key="watering_time", - name="Watering Time", + translation_key="watering_time", icon="mdi:water-pump", native_unit_of_measurement=UnitOfTime.MINUTES, ), diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 50d3fbaf4c3..8f079abcc7d 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -21,5 +21,28 @@ "title": "The Hydrawise YAML configuration import failed", "description": "Configuring Hydrawise using YAML is being removed but there was an {error_type} error importing your YAML configuration.\n\nEnsure connection to Hydrawise works and restart Home Assistant to try again or remove the Hydrawise YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } + }, + "entity": { + "binary_sensor": { + "watering": { + "name": "Watering" + } + }, + "sensor": { + "next_cycle": { + "name": "Next cycle" + }, + "watering_time": { + "name": "Watering time" + } + }, + "switch": { + "auto_watering": { + "name": "Automatic watering" + }, + "manual_watering": { + "name": "Manual watering" + } + } } } diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 88112d8e27a..8cdb5b67561 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -31,12 +31,12 @@ from .entity import HydrawiseEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="auto_watering", - name="Automatic Watering", + translation_key="auto_watering", device_class=SwitchDeviceClass.SWITCH, ), SwitchEntityDescription( key="manual_watering", - name="Manual Watering", + translation_key="manual_watering", device_class=SwitchDeviceClass.SWITCH, ), ) From 91bc65be9cc7e01b1d7447bbccd6e5f5cc6aa245 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 14:47:19 +0200 Subject: [PATCH 816/984] Add entity translations to SRP Energy (#99011) --- homeassistant/components/srp_energy/const.py | 3 --- homeassistant/components/srp_energy/sensor.py | 12 +++++++++--- homeassistant/components/srp_energy/strings.json | 7 +++++++ tests/components/srp_energy/test_sensor.py | 4 +--- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 5128dc48b35..bace71aca55 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -11,6 +11,3 @@ CONF_IS_TOU = "is_tou" PHOENIX_TIME_ZONE = "America/Phoenix" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) - -SENSOR_NAME = "Energy Usage" -SENSOR_TYPE = "usage" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index f6bd470df8a..37aacf4ff25 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -9,11 +9,12 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy 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 from . import SRPEnergyDataUpdateCoordinator -from .const import DEFAULT_NAME, DOMAIN, SENSOR_NAME +from .const import DOMAIN async def async_setup_entry( @@ -29,10 +30,11 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) """Implementation of a Srp Energy Usage sensor.""" _attr_attribution = "Powered by SRP Energy" - _attr_icon = "mdi:flash" _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_has_entity_name = True + _attr_translation_key = "energy_usage" def __init__( self, coordinator: SRPEnergyDataUpdateCoordinator, config_entry: ConfigEntry @@ -40,7 +42,11 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) """Initialize the SrpEntity class.""" super().__init__(coordinator) self._attr_unique_id = f"{config_entry.entry_id}_total_usage" - self._attr_name = f"{DEFAULT_NAME} {SENSOR_NAME}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + name="SRP Energy", + entry_type=DeviceEntryType.SERVICE, + ) @property def native_value(self) -> float: diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index 3dddd961194..fd963411198 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -19,5 +19,12 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "energy_usage": { + "name": "Energy usage" + } + } } } diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 1ae213e4bf1..32d2d971d2c 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -9,7 +9,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, ) @@ -29,7 +28,7 @@ async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: """Test the SrpEntity.""" - usage_state = hass.states.get("sensor.home_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_energy_usage") assert usage_state.state == "150.8" # Validate attributions @@ -43,7 +42,6 @@ async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: ) assert usage_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - assert usage_state.attributes.get(ATTR_ICON) == "mdi:flash" async def test_srp_entity_update_failed( From 31e9ca009991bdde292971356a194a1fe54f536a Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 26 Sep 2023 15:51:04 +0300 Subject: [PATCH 817/984] Handle authorization error in glances config flow (#100866) * Handle authroization error in glances config flow * Remove validate_input method and expections * update tests --- .../components/glances/config_flow.py | 31 +++++++------------ homeassistant/components/glances/strings.json | 3 +- tests/components/glances/test_config_flow.py | 30 ++++++++++++++---- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 58b81bc088e..e61761b26c6 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -3,10 +3,13 @@ from __future__ import annotations from typing import Any -from glances_api.exceptions import GlancesApiError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,7 +18,6 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from . import get_api @@ -41,15 +43,6 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: - """Validate the user input allows us to connect.""" - api = get_api(hass, data) - try: - await api.get_ha_sensor_data() - except GlancesApiError as err: - raise CannotConnect from err - - class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Glances config flow.""" @@ -64,19 +57,19 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) + api = get_api(self.hass, user_input) try: - await validate_input(self.hass, user_input) + await api.get_ha_sensor_data() + except GlancesApiAuthorizationError: + errors["base"] = "invalid_auth" + except GlancesApiConnectionError: + errors["base"] = "cannot_connect" + if not errors: return self.async_create_entry( title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", data=user_input, ) - except CannotConnect: - errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index b46716b43c0..684eb05d1d6 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -14,7 +14,8 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index d4d25d8b86f..b8ab7742088 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -1,7 +1,10 @@ """Tests for Glances config flow.""" from unittest.mock import MagicMock -from glances_api.exceptions import GlancesApiConnectionError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) import pytest from homeassistant import config_entries @@ -9,7 +12,7 @@ from homeassistant.components import glances from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import MOCK_USER_INPUT +from . import HA_SENSOR_DATA, MOCK_USER_INPUT from tests.common import MockConfigEntry, patch @@ -39,10 +42,19 @@ async def test_form(hass: HomeAssistant) -> None: assert result["data"] == MOCK_USER_INPUT -async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test to return error if we cannot connect.""" +@pytest.mark.parametrize( + ("error", "message"), + [ + (GlancesApiAuthorizationError, "invalid_auth"), + (GlancesApiConnectionError, "cannot_connect"), + ], +) +async def test_form_fails( + hass: HomeAssistant, error: Exception, message: str, mock_api: MagicMock +) -> None: + """Test flow fails when api exception is raised.""" - mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -51,7 +63,13 @@ async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> ) assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": message} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY async def test_form_already_configured(hass: HomeAssistant) -> None: From bd40cbcb2153f11acb0efd0359e0b626d10d66bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Sep 2023 16:19:57 +0200 Subject: [PATCH 818/984] Tweak pipeline.multiply_volume (#100905) --- .../components/assist_pipeline/pipeline.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index b047a09912a..a66408a01de 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1126,21 +1126,14 @@ class PipelineRun: def _multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes: """Multiplies 16-bit PCM samples by a constant.""" + + def _clamp(val: float) -> float: + """Clamp to signed 16-bit.""" + return max(-32768, min(32767, val)) + return array.array( "h", - [ - int( - # Clamp to signed 16-bit range - max( - -32767, - min( - 32767, - value * volume_multiplier, - ), - ) - ) - for value in array.array("h", chunk) - ], + (int(_clamp(value * volume_multiplier)) for value in array.array("h", chunk)), ).tobytes() From 822251a642dfc47448ec821d6f8da2ddf585f4c1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 26 Sep 2023 07:50:42 -0700 Subject: [PATCH 819/984] Update fitbit client to use asyncio (#100933) --- homeassistant/components/fitbit/api.py | 29 +++++++++++++++-------- homeassistant/components/fitbit/sensor.py | 23 +++++++++++++----- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 1b58d26e286..bf287471292 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -1,7 +1,7 @@ """API for fitbit bound to Home Assistant OAuth.""" import logging -from typing import Any +from typing import Any, cast from fitbit import Fitbit @@ -34,10 +34,12 @@ class FitbitApi: """Property to expose the underlying client library.""" return self._client - def get_user_profile(self) -> FitbitProfile: + async def async_get_user_profile(self) -> FitbitProfile: """Return the user profile from the API.""" if self._profile is None: - response: dict[str, Any] = self._client.user_profile_get() + response: dict[str, Any] = await self._hass.async_add_executor_job( + self._client.user_profile_get + ) _LOGGER.debug("user_profile_get=%s", response) profile = response["user"] self._profile = FitbitProfile( @@ -47,7 +49,7 @@ class FitbitApi: ) return self._profile - def get_unit_system(self) -> FitbitUnitSystem: + async def async_get_unit_system(self) -> FitbitUnitSystem: """Get the unit system to use when fetching timeseries. This is used in a couple ways. The first is to determine the request @@ -62,16 +64,18 @@ class FitbitApi: return self._unit_system # Use units consistent with the account user profile or fallback to the # home assistant unit settings. - profile = self.get_user_profile() + profile = await self.async_get_user_profile() if profile.locale == FitbitUnitSystem.EN_GB: return FitbitUnitSystem.EN_GB if self._hass.config.units is METRIC_SYSTEM: return FitbitUnitSystem.METRIC return FitbitUnitSystem.EN_US - def get_devices(self) -> list[FitbitDevice]: + async def async_get_devices(self) -> list[FitbitDevice]: """Return available devices.""" - devices: list[dict[str, str]] = self._client.get_devices() + devices: list[dict[str, str]] = await self._hass.async_add_executor_job( + self._client.get_devices + ) _LOGGER.debug("get_devices=%s", devices) return [ FitbitDevice( @@ -84,13 +88,18 @@ class FitbitApi: for device in devices ] - def get_latest_time_series(self, resource_type: str) -> dict[str, Any]: + async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]: """Return the most recent value from the time series for the specified resource type.""" # Set request header based on the configured unit system - self._client.system = self.get_unit_system() + self._client.system = await self.async_get_unit_system() - response: dict[str, Any] = self._client.time_series(resource_type, period="7d") + def _time_series() -> dict[str, Any]: + return cast( + dict[str, Any], self._client.time_series(resource_type, period="7d") + ) + + response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series) _LOGGER.debug("time_series(%s)=%s", resource_type, response) key = resource_type.replace("/", "-") dated_results: list[dict[str, Any]] = response[key] diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 653a4ee2508..e08f56e0e34 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,6 +1,7 @@ """Support for the Fitbit API.""" from __future__ import annotations +import asyncio from collections.abc import Callable from dataclasses import dataclass import datetime @@ -518,8 +519,12 @@ def setup_platform( authd_client.client.refresh_token() api = FitbitApi(hass, authd_client, config[CONF_UNIT_SYSTEM]) - user_profile = api.get_user_profile() - unit_system = api.get_unit_system() + user_profile = asyncio.run_coroutine_threadsafe( + api.async_get_user_profile(), hass.loop + ).result() + unit_system = asyncio.run_coroutine_threadsafe( + api.async_get_unit_system(), hass.loop + ).result() clock_format = config[CONF_CLOCK_FORMAT] monitored_resources = config[CONF_MONITORED_RESOURCES] @@ -539,7 +544,10 @@ def setup_platform( if description.key in monitored_resources ] if "devices/battery" in monitored_resources: - devices = api.get_devices() + devices = asyncio.run_coroutine_threadsafe( + api.async_get_devices(), + hass.loop, + ).result() entities.extend( [ FitbitSensor( @@ -708,21 +716,24 @@ class FitbitSensor(SensorEntity): return attrs - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data from the Fitbit API and update the states.""" resource_type = self.entity_description.key if resource_type == "devices/battery" and self.device is not None: device_id = self.device.id - registered_devs: list[FitbitDevice] = self.api.get_devices() + registered_devs: list[FitbitDevice] = await self.api.async_get_devices() self.device = next( device for device in registered_devs if device.id == device_id ) self._attr_native_value = self.device.battery else: - result = self.api.get_latest_time_series(resource_type) + result = await self.api.async_get_latest_time_series(resource_type) self._attr_native_value = self.entity_description.value_fn(result) + self.hass.async_add_executor_job(self._update_token) + + def _update_token(self) -> None: token = self.api.client.client.session.token config_contents = { ATTR_ACCESS_TOKEN: token.get("access_token"), From 4b39bf7e5bc49e9c2bebd88b1f236ca32968f8b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Sep 2023 09:57:25 -0500 Subject: [PATCH 820/984] Small cleanup to isy994 extra_state_attributes (#100935) --- homeassistant/components/isy994/entity.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 80319b83ba2..a93f2d91d31 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -112,19 +112,19 @@ class ISYNodeEntity(ISYEntity): other attributes which have been picked up from the event stream and the combined result are returned as the device state attributes. """ - attr = {} + attrs = self._attrs node = self._node # Insteon aux_properties are now their own sensors - if hasattr(self._node, "aux_properties") and node.protocol != PROTO_INSTEON: + # so we no longer need to add them to the attributes + if node.protocol != PROTO_INSTEON and hasattr(node, "aux_properties"): for name, value in self._node.aux_properties.items(): attr_name = COMMAND_FRIENDLY_NAME.get(name, name) - attr[attr_name] = str(value.formatted).lower() + attrs[attr_name] = str(value.formatted).lower() # If a Group/Scene, set a property if the entire scene is on/off - if hasattr(self._node, "group_all_on"): - attr["group_all_on"] = STATE_ON if self._node.group_all_on else STATE_OFF + if hasattr(node, "group_all_on"): + attrs["group_all_on"] = STATE_ON if node.group_all_on else STATE_OFF - self._attrs.update(attr) return self._attrs async def async_send_node_command(self, command: str) -> None: From c9a55c7f84da8b8e35a57722c910ac4a33fd3f58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Sep 2023 09:57:59 -0500 Subject: [PATCH 821/984] Cache the latest short term stat id for each metadata_id on each run (#100535) --- .../components/recorder/statistics.py | 199 +++++++++++++++--- tests/components/recorder/test_statistics.py | 21 ++ .../components/recorder/test_websocket_api.py | 8 + 3 files changed, 204 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 005859b865b..24fb209ae07 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -24,6 +24,7 @@ import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -141,10 +142,39 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS}, } +DATA_SHORT_TERM_STATISTICS_RUN_CACHE = "recorder_short_term_statistics_run_cache" + _LOGGER = logging.getLogger(__name__) +@dataclasses.dataclass(slots=True) +class ShortTermStatisticsRunCache: + """Cache for short term statistics runs.""" + + # This is a mapping of metadata_id:id of the last short term + # statistics run for each metadata_id + _latest_id_by_metadata_id: dict[int, int] = dataclasses.field(default_factory=dict) + + def get_latest_ids(self, metadata_ids: set[int]) -> dict[int, int]: + """Return the latest short term statistics ids for the metadata_ids.""" + return { + metadata_id: id_ + for metadata_id, id_ in self._latest_id_by_metadata_id.items() + if metadata_id in metadata_ids + } + + def set_latest_id_for_metadata_id(self, metadata_id: int, id_: int) -> None: + """Cache the latest id for the metadata_id.""" + self._latest_id_by_metadata_id[metadata_id] = id_ + + def set_latest_ids_for_metadata_ids( + self, metadata_id_to_id: dict[int, int] + ) -> None: + """Cache the latest id for the each metadata_id.""" + self._latest_id_by_metadata_id.update(metadata_id_to_id) + + class BaseStatisticsRow(TypedDict, total=False): """A processed row of statistic data.""" @@ -508,6 +538,8 @@ def _compile_statistics( platform_stats.extend(compiled.platform_stats) current_metadata.update(compiled.current_metadata) + new_short_term_stats: list[StatisticsBase] = [] + updated_metadata_ids: set[int] = set() # Insert collected statistics in the database for stats in platform_stats: modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add( @@ -515,12 +547,14 @@ def _compile_statistics( ) if modified_statistic_id is not None: modified_statistic_ids.add(modified_statistic_id) - _insert_statistics( + updated_metadata_ids.add(metadata_id) + if new_stat := _insert_statistics( session, StatisticsShortTerm, metadata_id, stats["stat"], - ) + ): + new_short_term_stats.append(new_stat) if start.minute == 55: # A full hour is ready, summarize it @@ -533,6 +567,23 @@ def _compile_statistics( if start.minute == 55: instance.hass.bus.fire(EVENT_RECORDER_HOURLY_STATISTICS_GENERATED) + if updated_metadata_ids: + # These are always the newest statistics, so we can update + # the run cache without having to check the start_ts. + session.flush() # populate the ids of the new StatisticsShortTerm rows + run_cache = get_short_term_statistics_run_cache(instance.hass) + # metadata_id is typed to allow None, but we know it's not None here + # so we can safely cast it to int. + run_cache.set_latest_ids_for_metadata_ids( + cast( + dict[int, int], + { + new_stat.metadata_id: new_stat.id + for new_stat in new_short_term_stats + }, + ) + ) + return modified_statistic_ids @@ -566,16 +617,19 @@ def _insert_statistics( table: type[StatisticsBase], metadata_id: int, statistic: StatisticData, -) -> None: +) -> StatisticsBase | None: """Insert statistics in the database.""" try: - session.add(table.from_stats(metadata_id, statistic)) + stat = table.from_stats(metadata_id, statistic) + session.add(stat) + return stat except SQLAlchemyError: _LOGGER.exception( "Unexpected exception when inserting statistics %s:%s ", metadata_id, statistic, ) + return None def _update_statistics( @@ -1809,24 +1863,26 @@ def get_last_short_term_statistics( ) -def _latest_short_term_statistics_stmt( - metadata_ids: list[int], +def get_latest_short_term_statistics_by_ids( + session: Session, ids: Iterable[int] +) -> list[Row]: + """Return the latest short term statistics for a list of ids.""" + stmt = _latest_short_term_statistics_by_ids_stmt(ids) + return list( + cast( + Sequence[Row], + execute_stmt_lambda_element(session, stmt, orm_rows=False), + ) + ) + + +def _latest_short_term_statistics_by_ids_stmt( + ids: Iterable[int], ) -> StatementLambdaElement: - """Create the statement for finding the latest short term stat rows.""" + """Create the statement for finding the latest short term stat rows by id.""" return lambda_stmt( - lambda: select(*QUERY_STATISTICS_SHORT_TERM).join( - ( - most_recent_statistic_row := ( - select( - StatisticsShortTerm.metadata_id, - func.max(StatisticsShortTerm.start_ts).label("start_max"), - ) - .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - .group_by(StatisticsShortTerm.metadata_id) - ).subquery() - ), - (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) - & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), + lambda: select(*QUERY_STATISTICS_SHORT_TERM).filter( + StatisticsShortTerm.id.in_(ids) ) ) @@ -1846,11 +1902,38 @@ def get_latest_short_term_statistics( ) if not metadata: return {} - metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) - stmt = _latest_short_term_statistics_stmt(metadata_ids) - stats = cast( - Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) + metadata_ids = set( + _extract_metadata_and_discard_impossible_columns(metadata, types) ) + run_cache = get_short_term_statistics_run_cache(hass) + # Try to find the latest short term statistics ids for the metadata_ids + # from the run cache first if we have it. If the run cache references + # a non-existent id because of a purge, we will detect it missing in the + # next step and run a query to re-populate the cache. + stats: list[Row] = [] + if metadata_id_to_id := run_cache.get_latest_ids(metadata_ids): + stats = get_latest_short_term_statistics_by_ids( + session, metadata_id_to_id.values() + ) + # If we are missing some metadata_ids in the run cache, we need run a query + # to populate the cache for each metadata_id, and then run another query + # to get the latest short term statistics for the missing metadata_ids. + if (missing_metadata_ids := metadata_ids - set(metadata_id_to_id)) and ( + found_latest_ids := { + latest_id + for metadata_id in missing_metadata_ids + if ( + latest_id := cache_latest_short_term_statistic_id_for_metadata_id( + run_cache, session, metadata_id + ) + ) + is not None + } + ): + stats.extend( + get_latest_short_term_statistics_by_ids(session, found_latest_ids) + ) + if not stats: return {} @@ -2221,9 +2304,77 @@ def _import_statistics_with_session( else: _insert_statistics(session, table, metadata_id, stat) + if table != StatisticsShortTerm: + return True + + # We just inserted new short term statistics, so we need to update the + # ShortTermStatisticsRunCache with the latest id for the metadata_id + run_cache = get_short_term_statistics_run_cache(instance.hass) + cache_latest_short_term_statistic_id_for_metadata_id( + run_cache, session, metadata_id + ) + return True +@singleton(DATA_SHORT_TERM_STATISTICS_RUN_CACHE) +def get_short_term_statistics_run_cache( + hass: HomeAssistant, +) -> ShortTermStatisticsRunCache: + """Get the short term statistics run cache.""" + return ShortTermStatisticsRunCache() + + +def cache_latest_short_term_statistic_id_for_metadata_id( + run_cache: ShortTermStatisticsRunCache, session: Session, metadata_id: int +) -> int | None: + """Cache the latest short term statistic for a given metadata_id. + + Returns the id of the latest short term statistic for the metadata_id + that was added to the cache, or None if no latest short term statistic + was found for the metadata_id. + """ + if latest := cast( + Sequence[Row], + execute_stmt_lambda_element( + session, + _find_latest_short_term_statistic_for_metadata_id_stmt(metadata_id), + orm_rows=False, + ), + ): + id_: int = latest[0].id + run_cache.set_latest_id_for_metadata_id(metadata_id, id_) + return id_ + return None + + +def _find_latest_short_term_statistic_for_metadata_id_stmt( + metadata_id: int, +) -> StatementLambdaElement: + """Create a statement to find the latest short term statistics for a metadata_id.""" + # + # This code only looks up one row, and should not be refactored to + # lookup multiple using func.max + # or similar, as that will cause the query to be significantly slower + # for DBMs such as PostgreSQL that will have to do a full scan + # + # For PostgreSQL a combined query plan looks like: + # (actual time=2.218..893.909 rows=170531 loops=1) + # + # For PostgreSQL a separate query plan looks like: + # (actual time=0.301..0.301 rows=1 loops=1) + # + # + return lambda_stmt( + lambda: select( + StatisticsShortTerm.id, + ) + .where(StatisticsShortTerm.metadata_id == metadata_id) + .order_by(StatisticsShortTerm.start_ts.desc()) + .limit(1) + ) + + @retryable_database_job("statistics") def import_statistics( instance: Recorder, diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ab89b82d713..e56b2b83274 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -24,6 +24,7 @@ from homeassistant.components.recorder.statistics import ( get_last_statistics, get_latest_short_term_statistics, get_metadata, + get_short_term_statistics_run_cache, list_statistic_ids, ) from homeassistant.components.recorder.table_managers.statistics_meta import ( @@ -176,6 +177,15 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) ) assert stats == {"sensor.test1": [expected_2]} + # Now wipe the latest_short_term_statistics_ids table and test again + # to make sure we can rebuild the missing data + run_cache = get_short_term_statistics_run_cache(instance.hass) + run_cache._latest_id_by_metadata_id = {} + stats = get_latest_short_term_statistics( + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} + ) + assert stats == {"sensor.test1": [expected_2]} + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) stats = get_latest_short_term_statistics( @@ -220,6 +230,17 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) ) assert stats == {} + # Delete again, and manually wipe the cache since we deleted all the data + instance.get_session().query(StatisticsShortTerm).delete() + run_cache = get_short_term_statistics_run_cache(instance.hass) + run_cache._latest_id_by_metadata_id = {} + + # And test again to make sure there is no data + stats = get_latest_short_term_statistics( + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} + ) + assert stats == {} + @pytest.fixture def mock_sensor_statistics(): diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index a9dc23ef5b3..38b657945f7 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -15,6 +15,7 @@ from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, get_metadata, + get_short_term_statistics_run_cache, list_statistic_ids, ) from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA @@ -302,6 +303,13 @@ async def test_statistic_during_period( ) await async_wait_recording_done(hass) + metadata = get_metadata(hass, statistic_ids={"sensor.test"}) + metadata_id = metadata["sensor.test"][0] + run_cache = get_short_term_statistics_run_cache(hass) + # Verify the import of the short term statistics + # also updates the run cache + assert run_cache.get_latest_ids({metadata_id}) is not None + # No data for this period yet await client.send_json( { From 6551e522250808d273624ea973fc5fb6cddf25d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Sep 2023 10:08:27 -0500 Subject: [PATCH 822/984] Bump zeroconf to 0.115.0 (#100931) --- 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 0b4db86dad7..9898c6a3496 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.114.0"] + "requirements": ["zeroconf==0.115.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 34e6a612789..d50af885442 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.1.0 yarl==1.9.2 -zeroconf==0.114.0 +zeroconf==0.115.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 7ec29468154..a708b081fa1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2775,7 +2775,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.114.0 +zeroconf==0.115.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 773f4fde707..2b70a81ec68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2066,7 +2066,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.114.0 +zeroconf==0.115.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From c823e407fdabc364c5149122653ea8c751e74a17 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Sep 2023 17:42:08 +0200 Subject: [PATCH 823/984] Tweak test wake_word.test_init.test_detected_entity (#100910) --- .../wake_word/snapshots/test_init.ambr | 17 ----------------- tests/components/wake_word/test_init.py | 7 ++++--- 2 files changed, 4 insertions(+), 20 deletions(-) delete mode 100644 tests/components/wake_word/snapshots/test_init.ambr diff --git a/tests/components/wake_word/snapshots/test_init.ambr b/tests/components/wake_word/snapshots/test_init.ambr deleted file mode 100644 index 60439d1109b..00000000000 --- a/tests/components/wake_word/snapshots/test_init.ambr +++ /dev/null @@ -1,17 +0,0 @@ -# serializer version: 1 -# name: test_detected_entity[None-test_ww] - None -# --- -# name: test_detected_entity[test_ww_2-test_ww_2] - None -# --- -# name: test_ws_detect - dict({ - 'event': dict({ - 'timestamp': 2048.0, - 'ww_id': 'test_ww', - }), - 'id': 1, - 'type': 'event', - }) -# --- diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index 1e03632d083..47b413db435 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -2,8 +2,8 @@ from collections.abc import AsyncIterable, Generator from pathlib import Path +from freezegun import freeze_time import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow @@ -155,6 +155,7 @@ async def test_config_entry_unload( assert config_entry.state == ConfigEntryState.NOT_LOADED +@freeze_time("2023-06-22 10:30:00+00:00") @pytest.mark.parametrize( ("ww_id", "expected_ww"), [ @@ -166,7 +167,6 @@ async def test_detected_entity( hass: HomeAssistant, tmp_path: Path, setup: MockProviderEntity, - snapshot: SnapshotAssertion, ww_id: str | None, expected_ww: str, ) -> None: @@ -180,11 +180,12 @@ async def test_detected_entity( # Need 2 seconds to trigger state = setup.state + assert state is None result = await setup.async_process_audio_stream(three_second_stream(), ww_id) assert result == wake_word.DetectionResult(expected_ww, 2048) assert state != setup.state - assert state == snapshot + assert setup.state == "2023-06-22T10:30:00+00:00" async def test_not_detected_entity( From 785b46af22bae707b13156c4edab92f9f06276da Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 26 Sep 2023 18:46:12 +0300 Subject: [PATCH 824/984] Add re-auth flow to glances integration (#100929) * Add reauth flow to glances integration. * add reauth string * add reauth strings --- .../components/glances/config_flow.py | 46 ++++++++++- .../components/glances/coordinator.py | 3 + homeassistant/components/glances/strings.json | 9 ++- tests/components/glances/test_config_flow.py | 78 +++++++++++++++++++ tests/components/glances/test_init.py | 26 +++++-- 5 files changed, 155 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index e61761b26c6..72555b629d7 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Glances.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from glances_api.exceptions import ( @@ -47,6 +48,49 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Glances config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors = {} + assert self._reauth_entry + if user_input is not None: + user_input = {**self._reauth_entry.data, **user_input} + api = get_api(self.hass, user_input) + try: + await api.get_ha_sensor_data() + except GlancesApiAuthorizationError: + errors["base"] = "invalid_auth" + except GlancesApiConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + description_placeholders={ + CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME] + }, + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -64,7 +108,7 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except GlancesApiConnectionError: errors["base"] = "cannot_connect" - if not errors: + else: return self.async_create_entry( title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", data=user_input, diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8d2bd0daaa3..9fa9346b95f 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -7,6 +7,7 @@ from glances_api import Glances, exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -36,6 +37,8 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get the latest data from the Glances REST API.""" try: data = await self.api.get_ha_sensor_data() + except exceptions.GlancesApiAuthorizationError as err: + raise ConfigEntryAuthFailed from err except exceptions.GlancesApiError as err: raise UpdateFailed from err return data or {} diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index 684eb05d1d6..fdd0c44b31b 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -11,6 +11,12 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } + }, + "reauth_confirm": { + "description": "The password for {username} is invalid.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -18,7 +24,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index b8ab7742088..87ec80da057 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -85,3 +85,81 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_success(hass: HomeAssistant) -> None: + """Test we can reauth.""" + entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + glances.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"username": "username"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (GlancesApiAuthorizationError, "invalid_auth"), + (GlancesApiConnectionError, "cannot_connect"), + ], +) +async def test_reauth_fails( + hass: HomeAssistant, error: Exception, message: str, mock_api: MagicMock +) -> None: + """Test we can reauth.""" + entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA] + result = await hass.config_entries.flow.async_init( + glances.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"username": "username"} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": message} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "password": "new-password", + }, + ) + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 546f57ac3d9..61cbc610060 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -1,7 +1,11 @@ """Tests for Glances integration.""" from unittest.mock import MagicMock -from glances_api.exceptions import GlancesApiConnectionError +from glances_api.exceptions import ( + GlancesApiAuthorizationError, + GlancesApiConnectionError, +) +import pytest from homeassistant.components.glances.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,15 +27,27 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state == ConfigEntryState.LOADED -async def test_conn_error(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test Glances failed due to connection error.""" +@pytest.mark.parametrize( + ("error", "entry_state"), + [ + (GlancesApiAuthorizationError, ConfigEntryState.SETUP_ERROR), + (GlancesApiConnectionError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_error( + hass: HomeAssistant, + error: Exception, + entry_state: ConfigEntryState, + mock_api: MagicMock, +) -> None: + """Test Glances failed due to api error.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) - mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = error await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is entry_state async def test_unload_entry(hass: HomeAssistant) -> None: From 59207be5f8bfe61bf5b78c2087bde1d1cceeaff2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 17:52:29 +0200 Subject: [PATCH 825/984] Add body_exists to MockRequest in aiohttp util (#100932) * Add body_exists to MockRequest in aiohttp util * Add body_exists to MockRequest in aiohttp util * Add body_exists to MockRequest in aiohttp util --- homeassistant/util/aiohttp.py | 5 +++++ tests/util/test_aiohttp.py | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index 6e2cfc5325d..ceb5e502221 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -66,6 +66,11 @@ class MockRequest: """Return the body as text.""" return MockStreamReader(self._content) + @property + def body_exists(self) -> bool: + """Return True if request has HTTP BODY, False otherwise.""" + return bool(self._text) + async def json(self, loads: JSONDecoder = json_loads) -> Any: """Return the body as JSON.""" return loads(self._text) diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index ebcc9cec526..76394b42491 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -12,12 +12,19 @@ async def test_request_json() -> None: async def test_request_text() -> None: - """Test a JSON request.""" + """Test bytes in request.""" request = aiohttp.MockRequest(b"hello", status=201, mock_source="test") + assert request.body_exists assert request.status == 201 assert await request.text() == "hello" +async def test_request_body_exists() -> None: + """Test body exists.""" + request = aiohttp.MockRequest(b"", mock_source="test") + assert not request.body_exists + + async def test_request_post_query() -> None: """Test a JSON request.""" request = aiohttp.MockRequest( From 82fdd8313fef764262fd9ea5dc334fa44a5b48e1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 26 Sep 2023 17:53:26 +0200 Subject: [PATCH 826/984] Update frontend manifest for new icons (#100936) --- homeassistant/components/frontend/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 59315e9f576..a5a4d76f9e7 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -156,9 +156,18 @@ MANIFEST_JSON = Manifest( "src": f"/static/icons/favicon-{size}x{size}.png", "sizes": f"{size}x{size}", "type": "image/png", - "purpose": "maskable any", + "purpose": "any", } for size in (192, 384, 512, 1024) + ] + + [ + { + "src": f"/static/icons/maskable_icon-{size}x{size}.png", + "sizes": f"{size}x{size}", + "type": "image/png", + "purpose": "maskable", + } + for size in (48, 72, 96, 128, 192, 384, 512) ], "screenshots": [ { @@ -171,6 +180,7 @@ MANIFEST_JSON = Manifest( "name": "Home Assistant", "short_name": "Assistant", "start_url": "/?homescreen=1", + "id": "/?homescreen=1", "theme_color": DEFAULT_THEME_COLOR, "prefer_related_applications": True, "related_applications": [ From 18fad569e0bcee508dc8f0887a430481f53a4d7c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 26 Sep 2023 18:01:01 +0200 Subject: [PATCH 827/984] Add support to remove orphan devices in AVM FRITZ!SmartHome (#100739) --- homeassistant/components/fritzbox/__init__.py | 20 +++++- tests/components/fritzbox/test_init.py | 66 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index d199d2c5a2c..8cb41ebcbe1 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -103,6 +103,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> bool: + """Remove Fritzbox config entry from a device.""" + coordinator: FritzboxDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + CONF_COORDINATOR + ] + + for identifier in device.identifiers: + if identifier[0] == DOMAIN and ( + identifier[1] in coordinator.data.devices + or identifier[1] in coordinator.data.templates + ): + return False + + return True + + class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): """Basis FritzBox entity.""" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index dd5a8127185..b07b8225c3e 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -21,12 +21,14 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from . import FritzDeviceSwitchMock, setup_config_entry from .const import CONF_FAKE_AIN, CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: @@ -250,6 +252,68 @@ async def test_unload_remove(hass: HomeAssistant, fritz: Mock) -> None: assert state is None +async def test_remove_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + fritz: Mock, +) -> None: + """Test removing of a device.""" + assert await async_setup_component(hass, "config", {}) + assert await setup_config_entry( + hass, + MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + f"{FB_DOMAIN}.{CONF_FAKE_NAME}", + FritzDeviceSwitchMock(), + fritz, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries() + assert len(entries) == 1 + + entry = entries[0] + assert entry.supports_remove_device + + entity = entity_registry.async_get("switch.fake_name") + good_device = device_registry.async_get(entity.device_id) + + orphan_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(FB_DOMAIN, "0000 000000")}, + ) + + # try to delete good_device + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": entry.entry_id, + "device_id": good_device.id, + } + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "unknown_error" + await hass.async_block_till_done() + + # try to delete orphan_device + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": entry.entry_id, + "device_id": orphan_device.id, + } + ) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when fritzbox is offline.""" entry = MockConfigEntry( From 074eb966ddaa61cc7e3b226d3c27678b364f7815 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 18:05:23 +0200 Subject: [PATCH 828/984] Add config flow to AfterShip (#100872) * Add config flow to Aftership * Add config flow to Aftership * Fix schema * Update homeassistant/components/aftership/strings.json Co-authored-by: Robert Resch * Fix feedback --------- Co-authored-by: Robert Resch --- .coveragerc | 3 +- .../components/aftership/__init__.py | 43 ++++++- .../components/aftership/config_flow.py | 90 ++++++++++++++ .../components/aftership/manifest.json | 1 + homeassistant/components/aftership/sensor.py | 46 ++++++-- .../components/aftership/strings.json | 25 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/aftership/__init__.py | 1 + tests/components/aftership/conftest.py | 14 +++ .../components/aftership/test_config_flow.py | 110 ++++++++++++++++++ 12 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/aftership/config_flow.py create mode 100644 tests/components/aftership/__init__.py create mode 100644 tests/components/aftership/conftest.py create mode 100644 tests/components/aftership/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 7a016dac370..21932b67437 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,7 +29,8 @@ omit = homeassistant/components/adguard/switch.py homeassistant/components/ads/* homeassistant/components/aemet/weather_update_coordinator.py - homeassistant/components/aftership/* + homeassistant/components/aftership/__init__.py + homeassistant/components/aftership/sensor.py homeassistant/components/agent_dvr/alarm_control_panel.py homeassistant/components/agent_dvr/camera.py homeassistant/components/agent_dvr/helpers.py diff --git a/homeassistant/components/aftership/__init__.py b/homeassistant/components/aftership/__init__.py index b063c919f18..66610e6e01b 100644 --- a/homeassistant/components/aftership/__init__.py +++ b/homeassistant/components/aftership/__init__.py @@ -1 +1,42 @@ -"""The aftership component.""" +"""The AfterShip integration.""" +from __future__ import annotations + +from pyaftership import AfterShip, AfterShipException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AfterShip from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + session = async_get_clientsession(hass) + aftership = AfterShip(api_key=entry.data[CONF_API_KEY], session=session) + + try: + await aftership.trackings.list() + except AfterShipException as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = aftership + + 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/aftership/config_flow.py b/homeassistant/components/aftership/config_flow.py new file mode 100644 index 00000000000..3da6ac9e3d5 --- /dev/null +++ b/homeassistant/components/aftership/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for AfterShip integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyaftership import AfterShip, AfterShipException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +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__) + + +class AfterShipConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for AfterShip.""" + + 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: + self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]}) + try: + aftership = AfterShip( + api_key=user_input[CONF_API_KEY], + session=async_get_clientsession(self.hass), + ) + await aftership.trackings.list() + except AfterShipException: + _LOGGER.exception("Aftership raised exception") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title="AfterShip", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import configuration from yaml.""" + try: + self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]}) + except AbortFlow as err: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_already_configured", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_already_configured", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AfterShip", + }, + ) + raise err + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AfterShip", + }, + ) + return self.async_create_entry( + title=config.get(CONF_NAME, "AfterShip"), + data={CONF_API_KEY: config[CONF_API_KEY]}, + ) diff --git a/homeassistant/components/aftership/manifest.json b/homeassistant/components/aftership/manifest.json index 1cfc88a6f9d..eb4fffa57bc 100644 --- a/homeassistant/components/aftership/manifest.json +++ b/homeassistant/components/aftership/manifest.json @@ -2,6 +2,7 @@ "domain": "aftership", "name": "AfterShip", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aftership", "iot_class": "cloud_polling", "requirements": ["pyaftership==21.11.0"] diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index d816afa3b17..a3b85f2188d 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -20,6 +21,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) 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.util import Throttle @@ -58,19 +60,43 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the AfterShip sensor platform.""" - apikey = config[CONF_API_KEY] - name = config[CONF_NAME] - - session = async_get_clientsession(hass) - aftership = AfterShip(api_key=apikey, session=session) - + aftership = AfterShip( + api_key=config[CONF_API_KEY], session=async_get_clientsession(hass) + ) try: await aftership.trackings.list() - except AfterShipException as err: - _LOGGER.error("No tracking data found. Check API key is correct: %s", err) - return + except AfterShipException: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders={ + "integration_title": "AfterShip", + "url": "/config/integrations/dashboard/add?domain=aftership", + }, + ) - async_add_entities([AfterShipSensor(aftership, name)], True) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AfterShip sensor entities based on a config entry.""" + aftership: AfterShip = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities([AfterShipSensor(aftership, config_entry.title)], True) async def handle_add_tracking(call: ServiceCall) -> None: """Call when a user adds a new Aftership tracking from Home Assistant.""" diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index a7ccdd48202..b49c19976a6 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -1,4 +1,19 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, "services": { "add_tracking": { "name": "Add tracking", @@ -32,5 +47,15 @@ } } } + }, + "issues": { + "deprecated_yaml_import_issue_already_configured": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but the YAML configuration was already imported.\n\nRemove the YAML configuration and restart Home Assistant." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The {integration_title} YAML configuration import failed", + "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f229d753fec..d240f868b3e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -23,6 +23,7 @@ FLOWS = { "adguard", "advantage_air", "aemet", + "aftership", "agent_dvr", "airly", "airnow", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ef79e680ea2..9ebe29c8a48 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -68,7 +68,7 @@ "aftership": { "name": "AfterShip", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "agent_dvr": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b70a81ec68..8aea760dd3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1188,6 +1188,9 @@ pyW215==0.7.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 +# homeassistant.components.aftership +pyaftership==21.11.0 + # homeassistant.components.airnow pyairnow==1.2.1 diff --git a/tests/components/aftership/__init__.py b/tests/components/aftership/__init__.py new file mode 100644 index 00000000000..cdc39e5edfc --- /dev/null +++ b/tests/components/aftership/__init__.py @@ -0,0 +1 @@ +"""Tests for the AfterShip integration.""" diff --git a/tests/components/aftership/conftest.py b/tests/components/aftership/conftest.py new file mode 100644 index 00000000000..e3fdc00bc30 --- /dev/null +++ b/tests/components/aftership/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the AfterShip 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.aftership.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/aftership/test_config_flow.py b/tests/components/aftership/test_config_flow.py new file mode 100644 index 00000000000..2ac5919a555 --- /dev/null +++ b/tests/components/aftership/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test AfterShip config flow.""" +from unittest.mock import AsyncMock, patch + +from pyaftership import AfterShipException + +from homeassistant.components.aftership.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "mock-api-key", + } + + +async def test_flow_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> None: + """Test handling invalid connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.side_effect = AfterShipException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "mock-api-key", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "mock-api-key", + } + + +async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test importing yaml config.""" + + with patch( + "homeassistant.components.aftership.config_flow.AfterShip", + return_value=AsyncMock(), + ) as mock_aftership: + mock_aftership.return_value.trackings.return_value.list.return_value = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: "yaml-api-key"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "AfterShip" + assert result["data"] == { + CONF_API_KEY: "yaml-api-key", + } + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_import_flow_already_exists(hass: HomeAssistant) -> None: + """Test importing yaml config where entry already exists.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "yaml-api-key"}) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_API_KEY: "yaml-api-key"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 734c4e8e32e1f035b1172b90b33b346ebc26eea1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Sep 2023 19:12:16 +0200 Subject: [PATCH 829/984] Rename WakeWord.ww_id to WakeWord.id (#100903) * Rename WakeWord.ww_id to WakeWord.wake_word_id * Revert unrelated changes * Rename to id * Correct rebase --- homeassistant/components/wake_word/models.py | 4 ++-- homeassistant/components/wyoming/wake_word.py | 5 ++--- tests/components/assist_pipeline/conftest.py | 6 +++--- .../assist_pipeline/snapshots/test_init.ambr | 2 +- .../snapshots/test_websocket.ambr | 4 ++-- tests/components/wake_word/test_init.py | 18 +++++++++--------- .../wyoming/snapshots/test_wake_word.ambr | 2 +- tests/components/wyoming/test_wake_word.py | 2 +- 8 files changed, 21 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/wake_word/models.py b/homeassistant/components/wake_word/models.py index 1ea154f1393..8e0699d97d0 100644 --- a/homeassistant/components/wake_word/models.py +++ b/homeassistant/components/wake_word/models.py @@ -6,7 +6,7 @@ from dataclasses import dataclass class WakeWord: """Wake word model.""" - ww_id: str + id: str name: str @@ -14,7 +14,7 @@ class WakeWord: class DetectionResult: """Result of wake word detection.""" - ww_id: str + wake_word_id: str """Id of detected wake word""" timestamp: int | None diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 710e3408c5a..45d33b2a28c 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -46,8 +46,7 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): wake_service = service.info.wake[0] self._supported_wake_words = [ - wake_word.WakeWord(ww_id=ww.name, name=ww.name) - for ww in wake_service.models + wake_word.WakeWord(id=ww.name, name=ww.name) for ww in wake_service.models ] self._attr_name = wake_service.name self._attr_unique_id = f"{config_entry.entry_id}-wake_word" @@ -111,7 +110,7 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): queued_audio = [audio_task.result()] return wake_word.DetectionResult( - ww_id=detection.name, + wake_word_id=detection.name, timestamp=detection.timestamp, queued_audio=queued_audio, ) diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 0ea92dd42fd..cde2666c1ea 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -184,18 +184,18 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): @property def supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" - return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] + return [wake_word.WakeWord(id="test_ww", name="Test Wake Word")] async def _async_process_audio_stream( self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" if wake_word_id is None: - wake_word_id = self.supported_wake_words[0].ww_id + wake_word_id = self.supported_wake_words[0].id async for chunk, timestamp in stream: if chunk.startswith(b"wake word"): return wake_word.DetectionResult( - ww_id=wake_word_id, + wake_word_id=wake_word_id, timestamp=timestamp, queued_audio=[(b"queued audio", 0)], ) diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f36a334d97d..2108b84460e 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -292,7 +292,7 @@ 'data': dict({ 'wake_word_output': dict({ 'timestamp': 2000, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }), 'type': , diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index dd88997262f..044e7758eb2 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -281,7 +281,7 @@ 'wake_word_output': dict({ 'queued_audio': None, 'timestamp': 1000, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }) # --- @@ -379,7 +379,7 @@ dict({ 'wake_word_output': dict({ 'timestamp': 0, - 'ww_id': 'test_ww', + 'wake_word_id': 'test_ww', }), }) # --- diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index 47b413db435..2fb7cbd0c97 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -41,8 +41,8 @@ class MockProviderEntity(wake_word.WakeWordDetectionEntity): def supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" return [ - wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word"), - wake_word.WakeWord(ww_id="test_ww_2", name="Test Wake Word 2"), + wake_word.WakeWord(id="test_ww", name="Test Wake Word"), + wake_word.WakeWord(id="test_ww_2", name="Test Wake Word 2"), ] async def _async_process_audio_stream( @@ -50,12 +50,12 @@ class MockProviderEntity(wake_word.WakeWordDetectionEntity): ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" if wake_word_id is None: - wake_word_id = self.supported_wake_words[0].ww_id + wake_word_id = self.supported_wake_words[0].id async for _chunk, timestamp in stream: if timestamp >= 2000: return wake_word.DetectionResult( - ww_id=wake_word_id, timestamp=timestamp + wake_word_id=wake_word_id, timestamp=timestamp ) # Not detected @@ -157,7 +157,7 @@ async def test_config_entry_unload( @freeze_time("2023-06-22 10:30:00+00:00") @pytest.mark.parametrize( - ("ww_id", "expected_ww"), + ("wake_word_id", "expected_ww"), [ (None, "test_ww"), ("test_ww_2", "test_ww_2"), @@ -167,7 +167,7 @@ async def test_detected_entity( hass: HomeAssistant, tmp_path: Path, setup: MockProviderEntity, - ww_id: str | None, + wake_word_id: str | None, expected_ww: str, ) -> None: """Test successful detection through entity.""" @@ -181,7 +181,7 @@ async def test_detected_entity( # Need 2 seconds to trigger state = setup.state assert state is None - result = await setup.async_process_audio_stream(three_second_stream(), ww_id) + result = await setup.async_process_audio_stream(three_second_stream(), wake_word_id) assert result == wake_word.DetectionResult(expected_ww, 2048) assert state != setup.state @@ -283,7 +283,7 @@ async def test_list_wake_words( assert msg["success"] assert msg["result"] == { "wake_words": [ - {"ww_id": "test_ww", "name": "Test Wake Word"}, - {"ww_id": "test_ww_2", "name": "Test Wake Word 2"}, + {"id": "test_ww", "name": "Test Wake Word"}, + {"id": "test_ww_2", "name": "Test Wake Word 2"}, ] } diff --git a/tests/components/wyoming/snapshots/test_wake_word.ambr b/tests/components/wyoming/snapshots/test_wake_word.ambr index 041112cb6ff..41518634a51 100644 --- a/tests/components/wyoming/snapshots/test_wake_word.ambr +++ b/tests/components/wyoming/snapshots/test_wake_word.ambr @@ -8,6 +8,6 @@ ), ]), 'timestamp': 0, - 'ww_id': 'Test Model', + 'wake_word_id': 'Test Model', }) # --- diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index 4ec471be7fd..eec5a16ff25 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -25,7 +25,7 @@ async def test_support(hass: HomeAssistant, init_wyoming_wake_word) -> None: assert entity is not None assert entity.supported_wake_words == [ - wake_word.WakeWord(ww_id="Test Model", name="Test Model") + wake_word.WakeWord(id="Test Model", name="Test Model") ] From 20a2e129fbafb354ca573872e251fd42a814e2e4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 26 Sep 2023 19:53:45 +0200 Subject: [PATCH 830/984] Intialize mqtt lock in an unknown state in pessimistic mode (#100943) Intialize mqtt lock as unknown in pessimistic mode --- homeassistant/components/mqtt/lock.py | 12 +++++++----- tests/components/mqtt/test_lock.py | 11 ++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index ebc4eced9c2..cdaa00e3d63 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -150,7 +150,6 @@ class MqttLock(MqttEntity, LockEntity): discovery_data: DiscoveryInfoType | None, ) -> None: """Initialize the lock.""" - self._attr_is_locked = False MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod @@ -160,10 +159,13 @@ class MqttLock(MqttEntity, LockEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = ( - config[CONF_OPTIMISTIC] or self._config.get(CONF_STATE_TOPIC) is None - ) - self._attr_assumed_state = bool(self._optimistic) + if ( + optimistic := config[CONF_OPTIMISTIC] + or config.get(CONF_STATE_TOPIC) is None + ): + self._attr_is_locked = False + self._optimistic = optimistic + self._attr_assumed_state = bool(optimistic) self._compiled_pattern = config.get(CONF_CODE_FORMAT) self._attr_code_format = ( diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index f9a33c211ee..0045e003804 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -22,6 +22,7 @@ from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -107,7 +108,7 @@ async def test_controlling_state_via_topic( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) assert not state.attributes.get(ATTR_SUPPORTED_FEATURES) @@ -137,7 +138,7 @@ async def test_controlling_non_default_state_via_topic( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "state-topic", payload) @@ -197,7 +198,7 @@ async def test_controlling_state_via_topic_and_json_message( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN async_fire_mqtt_message(hass, "state-topic", payload) @@ -256,7 +257,7 @@ async def test_controlling_non_default_state_via_topic_and_json_message( await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN async_fire_mqtt_message(hass, "state-topic", payload) @@ -574,7 +575,7 @@ async def test_sending_mqtt_commands_pessimistic( mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_UNKNOWN assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == LockEntityFeature.OPEN # send lock command to lock From bc665a1368f688bcf4acd35a1ecbd9a609d2e93d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 19:58:47 +0200 Subject: [PATCH 831/984] Remove setting name in AnthemAV config flow (#99148) --- homeassistant/components/anthemav/config_flow.py | 11 ++++------- homeassistant/components/anthemav/media_player.py | 4 ++-- tests/components/anthemav/conftest.py | 4 ++-- tests/components/anthemav/test_config_flow.py | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index 23694654eb3..e75c67cb2c5 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -9,8 +9,8 @@ from anthemav.connection import Connection from anthemav.device_error import DeviceError import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac @@ -43,7 +43,7 @@ async def connect_device(user_input: dict[str, Any]) -> Connection: return avr -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class AnthemAVConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthem A/V Receivers.""" VERSION = 1 @@ -57,9 +57,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - if CONF_NAME not in user_input: - user_input[CONF_NAME] = DEFAULT_NAME - errors = {} avr: Connection | None = None @@ -84,7 +81,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_MODEL] = avr.protocol.model await self.async_set_unique_id(user_input[CONF_MAC]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) finally: if avr is not None: avr.close() diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 4056a34995a..91f8536d348 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -30,7 +30,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - name = config_entry.data[CONF_NAME] + name = config_entry.title mac_address = config_entry.data[CONF_MAC] model = config_entry.data[CONF_MODEL] diff --git a/tests/components/anthemav/conftest.py b/tests/components/anthemav/conftest.py index 89dba9563d1..7797f08872f 100644 --- a/tests/components/anthemav/conftest.py +++ b/tests/components/anthemav/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -55,10 +55,10 @@ def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( domain=DOMAIN, + title="Anthem AV", data={ CONF_HOST: "1.1.1.1", CONF_PORT: 14999, - CONF_NAME: "Anthem AV", CONF_MAC: "00:00:00:00:00:01", CONF_MODEL: "MRX 520", }, diff --git a/tests/components/anthemav/test_config_flow.py b/tests/components/anthemav/test_config_flow.py index e62fb4ba52c..caa76006976 100644 --- a/tests/components/anthemav/test_config_flow.py +++ b/tests/components/anthemav/test_config_flow.py @@ -36,10 +36,10 @@ async def test_form_with_valid_connection( await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Anthem AV" assert result2["data"] == { "host": "1.1.1.1", "port": 14999, - "name": "Anthem AV", "mac": "00:00:00:00:00:01", "model": "MRX 520", } From 9be5005a706d0fab1076e4f363072a16f3ad6665 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 20:00:23 +0200 Subject: [PATCH 832/984] Use automatic title during config flow setup in Aurora (#99199) --- homeassistant/components/aurora/__init__.py | 4 +--- homeassistant/components/aurora/config_flow.py | 11 ++++------- homeassistant/components/aurora/const.py | 1 - homeassistant/components/aurora/coordinator.py | 4 +--- homeassistant/components/aurora/entity.py | 9 ++------- tests/components/aurora/test_config_flow.py | 3 +-- 6 files changed, 9 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index 6ffba5f13da..cf7b48412a7 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -5,7 +5,7 @@ import logging from auroranoaa import AuroraForecast from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -29,11 +29,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = conf[CONF_LONGITUDE] latitude = conf[CONF_LATITUDE] threshold = options.get(CONF_THRESHOLD, DEFAULT_THRESHOLD) - name = conf[CONF_NAME] coordinator = AuroraDataUpdateCoordinator( hass=hass, - name=name, api=api, latitude=latitude, longitude=longitude, diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index bbd0768e74a..8fa4b285758 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for SpaceX Launches and Starman.""" +"""Config flow for Aurora.""" from __future__ import annotations import logging @@ -8,7 +8,7 @@ from auroranoaa import AuroraForecast import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( @@ -16,7 +16,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaOptionsFlowHandler, ) -from .const import CONF_THRESHOLD, DEFAULT_NAME, DEFAULT_THRESHOLD, DOMAIN +from .const import CONF_THRESHOLD, DEFAULT_THRESHOLD, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -50,7 +50,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - name = user_input[CONF_NAME] longitude = user_input[CONF_LONGITUDE] latitude = user_input[CONF_LATITUDE] @@ -70,7 +69,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() return self.async_create_entry( - title=f"Aurora - {name}", data=user_input + title="Aurora visibility", data=user_input ) return self.async_show_form( @@ -78,13 +77,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_NAME): str, vol.Required(CONF_LONGITUDE): cv.longitude, vol.Required(CONF_LATITUDE): cv.latitude, } ), { - CONF_NAME: DEFAULT_NAME, CONF_LONGITUDE: self.hass.config.longitude, CONF_LATITUDE: self.hass.config.latitude, }, diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index 419a3c946e6..fef0b5e6352 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -6,4 +6,3 @@ AURORA_API = "aurora_api" CONF_THRESHOLD = "forecast_threshold" DEFAULT_THRESHOLD = 75 ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric Administration" -DEFAULT_NAME = "Aurora Visibility" diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index 0ab1be00902..9d4eb0aa681 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -18,7 +18,6 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - name: str, api: AuroraForecast, latitude: float, longitude: float, @@ -29,12 +28,11 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass=hass, logger=_LOGGER, - name=name, + name="Aurora", update_interval=timedelta(minutes=5), ) self.api = api - self.name = name self.latitude = int(latitude) self.longitude = int(longitude) self.threshold = int(threshold) diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index a52f523f667..1b7dfbe88e3 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -29,14 +29,9 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): self._attr_translation_key = translation_key self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" self._attr_icon = icon - - @property - def device_info(self) -> DeviceInfo: - """Define the device based on name.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(self.unique_id))}, + identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="NOAA", model="Aurora Visibility Sensor", - name=self.coordinator.name, ) diff --git a/tests/components/aurora/test_config_flow.py b/tests/components/aurora/test_config_flow.py index 401ee37382e..ebd7780900a 100644 --- a/tests/components/aurora/test_config_flow.py +++ b/tests/components/aurora/test_config_flow.py @@ -10,7 +10,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DATA = { - "name": "Home", "latitude": -10, "longitude": 10.2, } @@ -39,7 +38,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "Aurora - Home" + assert result2["title"] == "Aurora visibility" assert result2["data"] == DATA assert len(mock_setup_entry.mock_calls) == 1 From ae7ede12531b2dd50a553ff401015553e5cccf10 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 20:01:33 +0200 Subject: [PATCH 833/984] Call async added to hass super in Smart Meter Texas (#100445) --- homeassistant/components/smart_meter_texas/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 84ad68fabc3..f54da815b26 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -76,6 +76,7 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self): """Subscribe to updates.""" + await super().async_added_to_hass() self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) # If the background update finished before From bdfdeb2bc009d1edac77048adc7a3e31836cf1a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 20:02:00 +0200 Subject: [PATCH 834/984] Call async added to hass super in Risco (#100444) --- homeassistant/components/risco/entity.py | 9 +-------- homeassistant/components/risco/sensor.py | 7 ++----- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index f8869d75d4b..e522c29ce19 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -31,17 +31,10 @@ class RiscoCloudEntity(CoordinatorEntity[RiscoDataUpdateCoordinator]): def _get_data_from_coordinator(self) -> None: raise NotImplementedError - def _refresh_from_coordinator(self) -> None: + def _handle_coordinator_update(self) -> None: self._get_data_from_coordinator() self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self._refresh_from_coordinator) - ) - @property def _risco(self): """Return the Risco API object.""" diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index b196723afbe..1d60ea4d7c2 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -86,15 +86,12 @@ class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEnt self._attr_name = f"Risco {self.coordinator.risco.site_name} {name} Events" self._attr_device_class = SensorDeviceClass.TIMESTAMP - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" + await super().async_added_to_hass() self._entity_registry = er.async_get(self.hass) - self.async_on_remove( - self.coordinator.async_add_listener(self._refresh_from_coordinator) - ) - def _refresh_from_coordinator(self): + def _handle_coordinator_update(self): events = self.coordinator.data for event in reversed(events): if event.category_id in self._excludes: From d94b09655b78e69c1497ccf3cbca5e15569a49b0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Sep 2023 20:03:22 +0200 Subject: [PATCH 835/984] Use snapshot assertion for wiz diagnostics test (#99154) --- .../wiz/snapshots/test_diagnostics.ambr | 16 ++++++++++++++++ tests/components/wiz/test_diagnostics.py | 16 ++++++---------- 2 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 tests/components/wiz/snapshots/test_diagnostics.ambr diff --git a/tests/components/wiz/snapshots/test_diagnostics.ambr b/tests/components/wiz/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5fe9aa883a1 --- /dev/null +++ b/tests/components/wiz/snapshots/test_diagnostics.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'homeId': '**REDACTED**', + 'mocked': 'mocked', + 'roomId': '**REDACTED**', + }), + 'entry': dict({ + 'data': dict({ + 'host': '1.1.1.1', + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/wiz/test_diagnostics.py b/tests/components/wiz/test_diagnostics.py index 3bc95cf57ff..ef26e63069b 100644 --- a/tests/components/wiz/test_diagnostics.py +++ b/tests/components/wiz/test_diagnostics.py @@ -1,4 +1,6 @@ """Test WiZ diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from . import async_setup_integration @@ -8,17 +10,11 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" _, entry = await async_setup_integration(hass) - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag == { - "data": { - "homeId": "**REDACTED**", - "mocked": "mocked", - "roomId": "**REDACTED**", - }, - "entry": {"data": {"host": "1.1.1.1"}, "title": "Mock Title"}, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot From c7e4604cfd31671986920836f8f676df7692f9b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 20:03:44 +0200 Subject: [PATCH 836/984] Call async added to hass super in Airvisual (#100449) --- homeassistant/components/airvisual/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 8860db69b79..1403cc94346 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -424,6 +424,7 @@ class AirVisualEntity(CoordinatorEntity): # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" + await super().async_added_to_hass() @callback def update() -> None: From e921e4a662fa5f60f6dca3d41ba5e8c3a2ee8d11 Mon Sep 17 00:00:00 2001 From: Olen Date: Tue, 26 Sep 2023 20:04:17 +0200 Subject: [PATCH 837/984] Move fetching of sw_version for Twinkly (#100286) --- homeassistant/components/twinkly/__init__.py | 13 +++++++---- .../components/twinkly/diagnostics.py | 3 ++- homeassistant/components/twinkly/light.py | 23 ++++++++++++------- tests/components/twinkly/__init__.py | 1 - .../twinkly/snapshots/test_diagnostics.ambr | 2 +- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index 3b0228e64b0..897bfaf4e20 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -6,12 +6,12 @@ from aiohttp import ClientError from ttls.client import Twinkly from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_SW_VERSION, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN +from .const import ATTR_VERSION, CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN PLATFORMS = [Platform.LIGHT] @@ -30,12 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device_info = await client.get_details() + software_version = await client.get_firmware_version() except (asyncio.TimeoutError, ClientError) as exception: raise ConfigEntryNotReady from exception - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] = client - hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO] = device_info - + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: client, + DATA_DEVICE_INFO: device_info, + ATTR_SW_VERSION: software_version.get(ATTR_VERSION), + } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py index 06afba5782b..598eab0fca5 100644 --- a/homeassistant/components/twinkly/diagnostics.py +++ b/homeassistant/components/twinkly/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC +from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -34,6 +34,7 @@ async def async_get_config_entry_diagnostics( { "entry": entry.as_dict(), "device_info": hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO], + ATTR_SW_VERSION: hass.data[DOMAIN][entry.entry_id][ATTR_SW_VERSION], "attributes": attributes, }, TO_REDACT, diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 66f764f17f6..6d0b31b06ed 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -19,13 +19,13 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL +from homeassistant.const import ATTR_SW_VERSION, CONF_MODEL from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_VERSION, CONF_HOST, CONF_ID, CONF_NAME, @@ -52,8 +52,9 @@ async def async_setup_entry( client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] device_info = hass.data[DOMAIN][config_entry.entry_id][DATA_DEVICE_INFO] + software_version = hass.data[DOMAIN][config_entry.entry_id][ATTR_SW_VERSION] - entity = TwinklyLight(config_entry, client, device_info) + entity = TwinklyLight(config_entry, client, device_info, software_version) async_add_entities([entity], update_before_add=True) @@ -68,6 +69,7 @@ class TwinklyLight(LightEntity): conf: ConfigEntry, client: Twinkly, device_info, + software_version: str | None = None, ) -> None: """Initialize a TwinklyLight entity.""" self._attr_unique_id: str = conf.data[CONF_ID] @@ -98,7 +100,7 @@ class TwinklyLight(LightEntity): self._attr_available = False self._current_movie: dict[Any, Any] = {} self._movies: list[Any] = [] - self._software_version = "" + self._software_version = software_version # We guess that most devices are "new" and support effects self._attr_supported_features = LightEntityFeature.EFFECT @@ -135,16 +137,21 @@ class TwinklyLight(LightEntity): async def async_added_to_hass(self) -> None: """Device is added to hass.""" - software_version = await self._client.get_firmware_version() - if ATTR_VERSION in software_version: - self._software_version = software_version[ATTR_VERSION] - + if self._software_version: if AwesomeVersion(self._software_version) < AwesomeVersion( MIN_EFFECT_VERSION ): self._attr_supported_features = ( self.supported_features & ~LightEntityFeature.EFFECT ) + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + {(DOMAIN, self._attr_unique_id)}, set() + ) + if device_entry: + device_registry.async_update_device( + device_entry.id, sw_version=self._software_version + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index 0780bc0126f..bd51ac5d7cd 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -33,7 +33,6 @@ class ClientMock: "uuid": self.id, "device_name": TEST_NAME, "product_code": TEST_MODEL, - "sw_version": self.version, } @property diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index c5788444845..cda2ad3d60e 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -16,7 +16,6 @@ 'device_info': dict({ 'device_name': 'twinkly_test_device_name', 'product_code': 'twinkly_test_device_model', - 'sw_version': '2.8.10', 'uuid': '4c8fccf5-e08a-4173-92d5-49bf479252a2', }), 'entry': dict({ @@ -39,5 +38,6 @@ 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'version': 1, }), + 'sw_version': '2.8.10', }) # --- From 4d7e3d2b0f04592fd88a19047f0ef6b3b30cb961 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 26 Sep 2023 20:07:54 +0200 Subject: [PATCH 838/984] Remove redundant initial assigment for mqtt siren (#100945) --- homeassistant/components/mqtt/siren.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index fb0a05c93f5..77880c9a9ed 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -163,7 +163,6 @@ class MqttSiren(MqttEntity, SirenEntity): discovery_data: DiscoveryInfoType | None, ) -> None: """Initialize the MQTT siren.""" - self._extra_attributes: dict[str, Any] = {} MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod From e18ff032444f55808dcd48ffab23cf962161a289 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 26 Sep 2023 20:16:50 +0200 Subject: [PATCH 839/984] Improve Vodafone Station setup dialog messages (#100937) --- homeassistant/components/vodafone_station/config_flow.py | 6 ++++++ homeassistant/components/vodafone_station/strings.json | 7 +++++-- tests/components/vodafone_station/test_config_flow.py | 3 +++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index e4a087f6903..45bb263d371 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -68,10 +68,14 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): try: info = await validate_input(self.hass, user_input) + except aiovodafone_exceptions.AlreadyLogged: + errors["base"] = "already_logged" except aiovodafone_exceptions.CannotConnect: errors["base"] = "cannot_connect" except aiovodafone_exceptions.CannotAuthenticate: errors["base"] = "invalid_auth" + except aiovodafone_exceptions.ModelNotSupported: + errors["base"] = "model_not_supported" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -99,6 +103,8 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await validate_input(self.hass, {**self.entry.data, **user_input}) + except aiovodafone_exceptions.AlreadyLogged: + errors["base"] = "already_logged" except aiovodafone_exceptions.CannotConnect: errors["base"] = "cannot_connect" except aiovodafone_exceptions.CannotAuthenticate: diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 0c2a4a408dd..d0dcb46fd10 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -12,21 +12,24 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]" + "password": "[%key:common::config_flow::data::password%]" } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_logged": "User already logged-in, please try again later.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "model_not_supported": "The device model is currently unsupported.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { + "already_logged": "User already logged-in, please try again later.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "model_not_supported": "The device model is currently unsupported.", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 3d2ef0cf568..41efd8af00c 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -52,6 +52,8 @@ async def test_user(hass: HomeAssistant) -> None: [ (aiovodafone_exceptions.CannotConnect, "cannot_connect"), (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (aiovodafone_exceptions.AlreadyLogged, "already_logged"), + (aiovodafone_exceptions.ModelNotSupported, "model_not_supported"), (ConnectionResetError, "unknown"), ], ) @@ -152,6 +154,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: [ (aiovodafone_exceptions.CannotConnect, "cannot_connect"), (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (aiovodafone_exceptions.AlreadyLogged, "already_logged"), (ConnectionResetError, "unknown"), ], ) From 9254eea9e24f929b406ca0c45c116e554fecc08b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 26 Sep 2023 20:18:52 +0200 Subject: [PATCH 840/984] Remove unused attribute for MQTT lawn_mower (#100946) Remove unused attributes for MQTT lawn_mower --- homeassistant/components/mqtt/lawn_mower.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 42761d224f8..de2f7d47a46 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -127,7 +127,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): discovery_data: DiscoveryInfoType | None, ) -> None: """Initialize the MQTT lawn mower.""" - self._attr_current_option = None LawnMowerEntity.__init__(self) MqttEntity.__init__(self, hass, config, config_entry, discovery_data) From a9bcfe5700026a875175788e196e79b02406a73e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Sep 2023 20:24:55 +0200 Subject: [PATCH 841/984] Abort wake word detection when assist pipeline is modified (#100918) --- .../components/assist_pipeline/error.py | 8 +++ .../components/assist_pipeline/pipeline.py | 68 ++++++++++++++++--- .../assist_pipeline/snapshots/test_init.ambr | 35 ++++++++++ tests/components/assist_pipeline/test_init.py | 64 +++++++++++++++++ 4 files changed, 165 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index 094913424b6..209e2611ec0 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -22,6 +22,14 @@ class WakeWordDetectionError(PipelineError): """Error in wake-word-detection portion of pipeline.""" +class WakeWordDetectionAborted(WakeWordDetectionError): + """Wake-word-detection was aborted.""" + + def __init__(self) -> None: + """Set error message.""" + super().__init__("wake_word_detection_aborted", "") + + class WakeWordTimeoutError(WakeWordDetectionError): """Timeout when wake word was not detected.""" diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a66408a01de..7e4c71671ad 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -32,6 +32,7 @@ from homeassistant.components.tts.media_source import ( from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.collection import ( + CHANGE_UPDATED, CollectionError, ItemNotFound, SerializedStorageCollection, @@ -54,6 +55,7 @@ from .error import ( PipelineNotFound, SpeechToTextError, TextToSpeechError, + WakeWordDetectionAborted, WakeWordDetectionError, WakeWordTimeoutError, ) @@ -470,11 +472,13 @@ class PipelineRun: audio_settings: AudioSettings = field(default_factory=AudioSettings) id: str = field(default_factory=ulid_util.ulid) - stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False) - tts_engine: str = field(init=False) + stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) + tts_engine: str = field(init=False, repr=False) tts_options: dict | None = field(init=False, default=None) - wake_word_entity_id: str = field(init=False) - wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False) + wake_word_entity_id: str = field(init=False, repr=False) + wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) + + abort_wake_word_detection: bool = field(init=False, default=False) debug_recording_thread: Thread | None = None """Thread that records audio to debug_recording_dir""" @@ -485,7 +489,7 @@ class PipelineRun: audio_processor: AudioProcessor | None = None """VAD/noise suppression/auto gain""" - audio_processor_buffer: AudioBuffer = field(init=False) + audio_processor_buffer: AudioBuffer = field(init=False, repr=False) """Buffer used when splitting audio into chunks for audio processing""" def __post_init__(self) -> None: @@ -504,6 +508,7 @@ class PipelineRun: size_limit=STORED_PIPELINE_RUNS ) pipeline_data.pipeline_debug[self.pipeline.id][self.id] = PipelineRunDebug() + pipeline_data.pipeline_runs.add_run(self) # Initialize with audio settings self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES) @@ -548,6 +553,9 @@ class PipelineRun: ) ) + pipeline_data: PipelineData = self.hass.data[DOMAIN] + pipeline_data.pipeline_runs.remove_run(self) + async def prepare_wake_word_detection(self) -> None: """Prepare wake-word-detection.""" entity_id = self.pipeline.wake_word_entity or wake_word.async_default_entity( @@ -638,6 +646,8 @@ class PipelineRun: # All audio kept from right before the wake word was detected as # a single chunk. audio_chunks_for_stt.extend(stt_audio_buffer) + except WakeWordDetectionAborted: + raise except WakeWordTimeoutError: _LOGGER.debug("Timeout during wake word detection") raise @@ -696,6 +706,9 @@ class PipelineRun: """ chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate async for chunk in audio_stream: + if self.abort_wake_word_detection: + raise WakeWordDetectionAborted + if self.debug_recording_queue is not None: self.debug_recording_queue.put_nowait(chunk.audio) @@ -1547,13 +1560,48 @@ class PipelineStorageCollectionWebsocket( connection.send_result(msg["id"]) -@dataclass +class PipelineRuns: + """Class managing pipelineruns.""" + + def __init__(self, pipeline_store: PipelineStorageCollection) -> None: + """Initialize.""" + self._pipeline_runs: dict[str, list[PipelineRun]] = {} + self._pipeline_store = pipeline_store + pipeline_store.async_add_listener(self._change_listener) + + def add_run(self, pipeline_run: PipelineRun) -> None: + """Add pipeline run.""" + pipeline_id = pipeline_run.pipeline.id + if pipeline_id not in self._pipeline_runs: + self._pipeline_runs[pipeline_id] = [] + self._pipeline_runs[pipeline_id].append(pipeline_run) + + def remove_run(self, pipeline_run: PipelineRun) -> None: + """Remove pipeline run.""" + pipeline_id = pipeline_run.pipeline.id + self._pipeline_runs[pipeline_id].remove(pipeline_run) + + async def _change_listener( + self, change_type: str, item_id: str, change: dict + ) -> None: + """Handle pipeline store changes.""" + if change_type != CHANGE_UPDATED: + return + if pipeline_runs := self._pipeline_runs.get(item_id): + # Create a temporary list in case the list is modified while we iterate + for pipeline_run in list(pipeline_runs): + pipeline_run.abort_wake_word_detection = True + + class PipelineData: """Store and debug data stored in hass.data.""" - pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] - pipeline_store: PipelineStorageCollection - pipeline_devices: set[str] = field(default_factory=set, init=False) + def __init__(self, pipeline_store: PipelineStorageCollection) -> None: + """Initialize.""" + self.pipeline_store = pipeline_store + self.pipeline_debug: dict[str, LimitedSizeDict[str, PipelineRunDebug]] = {} + self.pipeline_devices: set[str] = set() + self.pipeline_runs = PipelineRuns(pipeline_store) @dataclass @@ -1605,4 +1653,4 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: PIPELINE_FIELDS, PIPELINE_FIELDS, ).async_setup(hass) - return PipelineData({}, pipeline_store) + return PipelineData(pipeline_store) diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 2108b84460e..3f0582f2bfb 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -377,3 +377,38 @@ }), ]) # --- +# name: test_wake_word_detection_aborted + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'code': 'wake_word_detection_aborted', + 'message': '', + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index b41e23d7a0d..5258736c89f 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -563,3 +563,67 @@ async def test_pipeline_saved_audio_write_error( 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, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream with wake word.""" + + events: list[assist_pipeline.PipelineEvent] = [] + + async def audio_data(): + yield b"silence!" + yield b"wake word!" + yield b"part1" + yield b"part2" + yield b"" + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + conversation_id=None, + device_id=None, + 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(), + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output=None, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + audio_settings=assist_pipeline.AudioSettings( + is_vad_enabled=False, is_chunking_enabled=False + ), + ), + ) + await pipeline_input.validate() + + updates = pipeline.to_json() + updates.pop("id") + await pipeline_store.async_update_item( + pipeline_id, + updates, + ) + await pipeline_input.execute() + + assert process_events(events) == snapshot From 60dd5f1d505cea46b9200a2a913d3d462fae2ba5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 26 Sep 2023 20:27:34 +0200 Subject: [PATCH 842/984] Bump aiovodafone to 0.3.1 (#100944) --- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 2d72e7d9482..d37fed9564f 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.3.0"] + "requirements": ["aiovodafone==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a708b081fa1..a714a4e64b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ aiounifi==62 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.3.0 +aiovodafone==0.3.1 # homeassistant.components.waqi aiowaqi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8aea760dd3c..26dc3625f09 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,7 +344,7 @@ aiounifi==62 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.3.0 +aiovodafone==0.3.1 # homeassistant.components.waqi aiowaqi==0.2.1 From 402859697708b12feba1a7722e8721b07fd50b54 Mon Sep 17 00:00:00 2001 From: Edouard Lafargue Date: Tue, 26 Sep 2023 11:41:34 -0700 Subject: [PATCH 843/984] Add Medcom Bluetooth integration (#100289) Co-authored-by: J. Nick Koston --- .coveragerc | 2 + CODEOWNERS | 2 + .../components/medcom_ble/__init__.py | 74 ++++++ .../components/medcom_ble/config_flow.py | 147 ++++++++++++ homeassistant/components/medcom_ble/const.py | 10 + .../components/medcom_ble/manifest.json | 15 ++ homeassistant/components/medcom_ble/sensor.py | 104 +++++++++ .../components/medcom_ble/strings.json | 30 +++ homeassistant/generated/bluetooth.py | 4 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/medcom_ble/__init__.py | 111 +++++++++ tests/components/medcom_ble/conftest.py | 8 + .../components/medcom_ble/test_config_flow.py | 218 ++++++++++++++++++ 16 files changed, 738 insertions(+) create mode 100644 homeassistant/components/medcom_ble/__init__.py create mode 100644 homeassistant/components/medcom_ble/config_flow.py create mode 100644 homeassistant/components/medcom_ble/const.py create mode 100644 homeassistant/components/medcom_ble/manifest.json create mode 100644 homeassistant/components/medcom_ble/sensor.py create mode 100644 homeassistant/components/medcom_ble/strings.json create mode 100644 tests/components/medcom_ble/__init__.py create mode 100644 tests/components/medcom_ble/conftest.py create mode 100644 tests/components/medcom_ble/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 21932b67437..b2beacfe5a5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -726,6 +726,8 @@ omit = homeassistant/components/matter/__init__.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py + homeassistant/components/medcom_ble/__init__.py + homeassistant/components/medcom_ble/sensor.py homeassistant/components/mediaroom/media_player.py homeassistant/components/melcloud/__init__.py homeassistant/components/melcloud/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index e728d70c1bc..1005fb80094 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -742,6 +742,8 @@ build.json @home-assistant/supervisor /tests/components/mazda/ @bdr99 /homeassistant/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery +/homeassistant/components/medcom_ble/ @elafargue +/tests/components/medcom_ble/ @elafargue /homeassistant/components/media_extractor/ @joostlek /tests/components/media_extractor/ @joostlek /homeassistant/components/media_player/ @home-assistant/core diff --git a/homeassistant/components/medcom_ble/__init__.py b/homeassistant/components/medcom_ble/__init__.py new file mode 100644 index 00000000000..a129c4fc7f9 --- /dev/null +++ b/homeassistant/components/medcom_ble/__init__.py @@ -0,0 +1,74 @@ +"""The Medcom BLE integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from bleak import BleakError +from medcom_ble import MedcomBleDeviceData + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +# Supported platforms +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Medcom BLE radiation monitor from a config entry.""" + + address = entry.unique_id + elevation = hass.config.elevation + is_metric = hass.config.units is METRIC_SYSTEM + assert address is not None + + ble_device = bluetooth.async_ble_device_from_address(hass, address) + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find Medcom BLE device with address {address}" + ) + + async def _async_update_method(): + """Get data from Medcom BLE radiation monitor.""" + ble_device = bluetooth.async_ble_device_from_address(hass, address) + inspector = MedcomBleDeviceData(_LOGGER, elevation, is_metric) + + try: + data = await inspector.update_device(ble_device) + except BleakError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_async_update_method, + update_interval=timedelta(seconds=DEFAULT_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) + + 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/medcom_ble/config_flow.py b/homeassistant/components/medcom_ble/config_flow.py new file mode 100644 index 00000000000..30a87afbb72 --- /dev/null +++ b/homeassistant/components/medcom_ble/config_flow.py @@ -0,0 +1,147 @@ +"""Config flow for Medcom BlE integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from bleak import BleakError +from bluetooth_data_tools import human_readable_name +from medcom_ble import MedcomBleDevice, MedcomBleDeviceData +from medcom_ble.const import INSPECTOR_SERVICE_UUID +import voluptuous as vol + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import AbortFlow, FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class InspectorBLEConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Medcom BLE radiation monitors.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfo | None = None + self._discovered_devices: dict[str, BluetoothServiceInfo] = {} + + async def _get_device_data( + self, service_info: BluetoothServiceInfo + ) -> MedcomBleDevice: + ble_device = bluetooth.async_ble_device_from_address( + self.hass, service_info.address + ) + if ble_device is None: + _LOGGER.debug("no ble_device in _get_device_data") + raise AbortFlow("cannot_connect") + + inspector = MedcomBleDeviceData(_LOGGER) + + return await inspector.update_device(ble_device) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered BLE device: %s", discovery_info.name) + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + # We always will have self._discovery_info be a BluetoothServiceInfo at this point + # and this helps mypy not complain + assert self._discovery_info is not None + + if user_input is None: + name = self._discovery_info.name or self._discovery_info.address + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": name}, + ) + + return await self.async_step_check_connection() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[address] + return await self.async_step_check_connection() + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + _LOGGER.debug( + "Detected a device that's already configured: %s", address + ) + continue + + if INSPECTOR_SERVICE_UUID not in discovery_info.service_uuids: + continue + + self._discovered_devices[discovery_info.address] = discovery_info + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + titles = { + address: discovery.name + for address, discovery in self._discovered_devices.items() + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In(titles), + }, + ), + ) + + async def async_step_check_connection(self) -> FlowResult: + """Check we can connect to the device before considering the configuration is successful.""" + # We always will have self._discovery_info be a BluetoothServiceInfo at this point + # and this helps mypy not complain + assert self._discovery_info is not None + + _LOGGER.debug("Checking device connection: %s", self._discovery_info.name) + try: + await self._get_device_data(self._discovery_info) + except BleakError: + return self.async_abort(reason="cannot_connect") + except AbortFlow: + raise + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception( + "Error occurred reading information from %s: %s", + self._discovery_info.address, + err, + ) + return self.async_abort(reason="unknown") + _LOGGER.debug("Device connection successful, proceeding") + return self.async_create_entry(title=self._discovery_info.name, data={}) diff --git a/homeassistant/components/medcom_ble/const.py b/homeassistant/components/medcom_ble/const.py new file mode 100644 index 00000000000..3929b5d302b --- /dev/null +++ b/homeassistant/components/medcom_ble/const.py @@ -0,0 +1,10 @@ +"""Constants for the Medcom BLE integration.""" + +DOMAIN = "medcom_ble" + +# 5 minutes scan interval, which is perfectly +# adequate for background monitoring +DEFAULT_SCAN_INTERVAL = 300 + +# Units for the radiation monitors +UNIT_CPM = "CPM" diff --git a/homeassistant/components/medcom_ble/manifest.json b/homeassistant/components/medcom_ble/manifest.json new file mode 100644 index 00000000000..4aacae4647d --- /dev/null +++ b/homeassistant/components/medcom_ble/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "medcom_ble", + "name": "Medcom Bluetooth", + "bluetooth": [ + { + "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f" + } + ], + "codeowners": ["@elafargue"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/medcom_ble", + "iot_class": "local_polling", + "requirements": ["medcom-ble==0.1.1"] +} diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py new file mode 100644 index 00000000000..4c7488ddc12 --- /dev/null +++ b/homeassistant/components/medcom_ble/sensor.py @@ -0,0 +1,104 @@ +"""Support for Medcom BLE radiation monitor sensors.""" +from __future__ import annotations + +import logging + +from medcom_ble import MedcomBleDevice + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, UNIT_CPM + +_LOGGER = logging.getLogger(__name__) + +SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { + "cpm": SensorEntityDescription( + key="cpm", + translation_key="cpm", + native_unit_of_measurement=UNIT_CPM, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radioactive", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Medcom BLE radiation monitor sensors.""" + + coordinator: DataUpdateCoordinator[MedcomBleDevice] = hass.data[DOMAIN][ + entry.entry_id + ] + + entities = [] + _LOGGER.debug("got sensors: %s", coordinator.data.sensors) + for sensor_type, sensor_value in coordinator.data.sensors.items(): + if sensor_type not in SENSORS_MAPPING_TEMPLATE: + _LOGGER.debug( + "Unknown sensor type detected: %s, %s", + sensor_type, + sensor_value, + ) + continue + entities.append( + MedcomSensor(coordinator, SENSORS_MAPPING_TEMPLATE[sensor_type]) + ) + + async_add_entities(entities) + + +class MedcomSensor( + CoordinatorEntity[DataUpdateCoordinator[MedcomBleDevice]], SensorEntity +): + """Medcom BLE radiation monitor sensors for the device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator[MedcomBleDevice], + entity_description: SensorEntityDescription, + ) -> None: + """Populate the medcom entity with relevant data.""" + super().__init__(coordinator) + self.entity_description = entity_description + medcom_device = coordinator.data + + name = medcom_device.name + if identifier := medcom_device.identifier: + name += f" ({identifier})" + + self._attr_unique_id = f"{medcom_device.address}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + connections={ + ( + CONNECTION_BLUETOOTH, + medcom_device.address, + ) + }, + name=name, + manufacturer=medcom_device.manufacturer, + hw_version=medcom_device.hw_version, + sw_version=medcom_device.sw_version, + model=medcom_device.model, + ) + + @property + def native_value(self) -> float: + """Return the value reported by the sensor.""" + return self.coordinator.data.sensors[self.entity_description.key] diff --git a/homeassistant/components/medcom_ble/strings.json b/homeassistant/components/medcom_ble/strings.json new file mode 100644 index 00000000000..6ea6c0566ed --- /dev/null +++ b/homeassistant/components/medcom_ble/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "cpm": { + "name": "Counts per minute" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 5784667bc67..c2b24b68d29 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -304,6 +304,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "led_ble", "local_name": "AP-*", }, + { + "domain": "medcom_ble", + "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f", + }, { "domain": "melnor", "manufacturer_data_start": [ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d240f868b3e..acf324c107f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -271,6 +271,7 @@ FLOWS = { "matter", "mazda", "meater", + "medcom_ble", "melcloud", "melnor", "met", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9ebe29c8a48..394bfa4f391 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3270,6 +3270,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "medcom_ble": { + "name": "Medcom Bluetooth", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "media_extractor": { "name": "Media Extractor", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index a714a4e64b4..8c2ef0e4b71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1197,6 +1197,9 @@ mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.medcom_ble +medcom-ble==0.1.1 + # homeassistant.components.melnor melnor-bluetooth==0.0.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26dc3625f09..3da744aed73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -929,6 +929,9 @@ mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.medcom_ble +medcom-ble==0.1.1 + # homeassistant.components.melnor melnor-bluetooth==0.0.25 diff --git a/tests/components/medcom_ble/__init__.py b/tests/components/medcom_ble/__init__.py new file mode 100644 index 00000000000..e38b8ce8f01 --- /dev/null +++ b/tests/components/medcom_ble/__init__.py @@ -0,0 +1,111 @@ +"""Tests for the Medcom Inspector BLE integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from medcom_ble import MedcomBleDevice, MedcomBleDeviceData + +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.medcom_ble.async_setup_entry", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): + """Patch async ble device from address to return a given value.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + +def patch_medcom_ble(return_value=MedcomBleDevice, side_effect=None): + """Patch medcom-ble device fetcher with given values and effects.""" + return patch.object( + MedcomBleDeviceData, + "update_device", + return_value=return_value, + side_effect=side_effect, + ) + + +MEDCOM_SERVICE_INFO = BluetoothServiceInfoBleak( + name="InspectorBLE-D9A0", + address="a0:d9:5a:57:0b:00", + device=generate_ble_device( + address="a0:d9:5a:57:0b:00", + name="InspectorBLE-D9A0", + ), + rssi=-54, + manufacturer_data={}, + service_data={ + # Sensor data + "d68236af-266f-4486-b42d-80356ed5afb7": bytearray(b" 45,"), + # Manufacturer + "00002a29-0000-1000-8000-00805f9b34fb": bytearray(b"International Medcom"), + # Model + "00002a24-0000-1000-8000-00805f9b34fb": bytearray(b"Inspector-BLE"), + # Identifier + "00002a25-0000-1000-8000-00805f9b34fb": bytearray(b"\xa0\xd9\x5a\x57\x0b\x00"), + # SW Version + "00002a26-0000-1000-8000-00805f9b34fb": bytearray(b"170602"), + # HW Version + "00002a27-0000-1000-8000-00805f9b34fb": bytearray(b"2.0"), + }, + service_uuids=[ + "39b31fec-b63a-4ef7-b163-a7317872007f", + "00002a29-0000-1000-8000-00805f9b34fb", + "00002a24-0000-1000-8000-00805f9b34fb", + "00002a25-0000-1000-8000-00805f9b34fb", + "00002a26-0000-1000-8000-00805f9b34fb", + "00002a27-0000-1000-8000-00805f9b34fb", + ], + source="local", + advertisement=generate_advertisement_data( + tx_power=8, + service_uuids=["39b31fec-b63a-4ef7-b163-a7317872007f"], + ), + connectable=True, + time=0, +) + +UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( + name="unknown", + address="00:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + device=generate_ble_device( + "00:cc:cc:cc:cc:cc", + "unknown", + ), + advertisement=generate_advertisement_data( + manufacturer_data={}, + service_uuids=[], + ), + connectable=True, + time=0, +) + +MEDCOM_DEVICE_INFO = MedcomBleDevice( + manufacturer="International Medcom", + hw_version="2.0", + sw_version="170602", + model="Inspector BLE", + model_raw="InspectorBLE-D9A0", + name="Inspector BLE", + identifier="a0d95a570b00", + sensors={ + "cpm": 45, + }, + address="a0:d9:5a:57:0b:00", +) diff --git a/tests/components/medcom_ble/conftest.py b/tests/components/medcom_ble/conftest.py new file mode 100644 index 00000000000..7c5b0dad22e --- /dev/null +++ b/tests/components/medcom_ble/conftest.py @@ -0,0 +1,8 @@ +"""Common fixtures for the Medcom Inspector BLE tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/medcom_ble/test_config_flow.py b/tests/components/medcom_ble/test_config_flow.py new file mode 100644 index 00000000000..620b6811757 --- /dev/null +++ b/tests/components/medcom_ble/test_config_flow.py @@ -0,0 +1,218 @@ +"""Test the Medcom Inspector BLE config flow.""" +from unittest.mock import patch + +from bleak import BleakError +from medcom_ble import MedcomBleDevice + +from homeassistant import config_entries +from homeassistant.components.medcom_ble.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + MEDCOM_DEVICE_INFO, + MEDCOM_SERVICE_INFO, + UNKNOWN_SERVICE_INFO, + patch_async_ble_device_from_address, + patch_async_setup_entry, + patch_medcom_ble, +) + +from tests.common import MockConfigEntry + + +async def test_bluetooth_discovery(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MEDCOM_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": "InspectorBLE-D9A0"} + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + MedcomBleDevice( + manufacturer="International Medcom", + model="Inspector BLE", + model_raw="Inspector-BLE", + name="Inspector BLE", + identifier="a0d95a570b00", + ) + ): + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"not": "empty"} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "InspectorBLE-D9A0" + assert result["result"].unique_id == "a0:d9:5a:57:0b:00" + + +async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None: + """Test discovery via bluetooth with a valid device when already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="a0:d9:5a:57:0b:00", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MEDCOM_DEVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test the user initiated form.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "a0:d9:5a:57:0b:00": "InspectorBLE-D9A0" + } + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + MedcomBleDevice( + manufacturer="International Medcom", + model="Inspector BLE", + model_raw="Inspector-BLE", + name="Inspector BLE", + identifier="a0d95a570b00", + ) + ), patch( + "homeassistant.components.medcom_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "InspectorBLE-D9A0" + assert result["result"].unique_id == "a0:d9:5a:57:0b:00" + + +async def test_user_setup_no_device(hass: HomeAssistant) -> None: + """Test the user initiated form without any device detected.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_existing_and_unknown_device(hass: HomeAssistant) -> None: + """Test the user initiated form with existing devices and unknown ones.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="00:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_INFO, MEDCOM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_setup_unknown_device(hass: HomeAssistant) -> None: + """Test the user initiated form with only unknown devices.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_unknown_error(hass: HomeAssistant) -> None: + """Test the user initiated form with an unknown error.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + None, Exception() + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_user_setup_unable_to_connect(hass: HomeAssistant) -> None: + """Test the user initiated form with a device that's failing connection.""" + with patch( + "homeassistant.components.medcom_ble.config_flow.async_discovered_service_info", + return_value=[MEDCOM_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "a0:d9:5a:57:0b:00": "InspectorBLE-D9A0" + } + + with patch_async_ble_device_from_address(MEDCOM_SERVICE_INFO), patch_medcom_ble( + side_effect=BleakError("An error") + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "a0:d9:5a:57:0b:00"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" From b281fa17fcbd3913aa0b237a7fee3b136b24dfe3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 26 Sep 2023 21:07:27 +0200 Subject: [PATCH 844/984] Simplify wake_word/info + improve test coverage (#100902) * Simplify wake_word/info + improve test coverage * Fix test * Revert unrelated changes --- .../components/wake_word/__init__.py | 3 +-- tests/components/wake_word/test_init.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 9ce9cca75ff..6c55bd8e7e7 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import AsyncIterable -import dataclasses import logging from typing import final @@ -150,5 +149,5 @@ def websocket_entity_info( connection.send_result( msg["id"], - {"wake_words": [dataclasses.asdict(ww) for ww in entity.supported_wake_words]}, + {"wake_words": entity.supported_wake_words}, ) diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index 2fb7cbd0c97..5d1cc5a4b3f 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -287,3 +287,24 @@ async def test_list_wake_words( {"id": "test_ww_2", "name": "Test Wake Word 2"}, ] } + + +async def test_list_wake_words_unknown_entity( + hass: HomeAssistant, + setup: MockProviderEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the list_wake_words websocket command works.""" + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "wake_word/info", + "entity_id": "wake_word.blah", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"] == {"code": "not_found", "message": "Entity not found"} From 42b006a108ff23d5922ce386e711cc7065cc137f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 26 Sep 2023 15:48:36 -0400 Subject: [PATCH 845/984] Update MyQ to use python-myq 3.1.9 (#100949) --- CODEOWNERS | 4 ++-- homeassistant/components/myq/manifest.json | 4 ++-- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 1005fb80094..a6823b0fa45 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -809,8 +809,8 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core -/homeassistant/components/myq/ @ehendrix23 -/tests/components/myq/ @ehendrix23 +/homeassistant/components/myq/ @ehendrix23 @Lash-L +/tests/components/myq/ @ehendrix23 @Lash-L /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 5e03f962d15..02bf454bc3e 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -1,7 +1,7 @@ { "domain": "myq", "name": "MyQ", - "codeowners": ["@ehendrix23"], + "codeowners": ["@ehendrix23", "@Lash-L"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["pkce", "pymyq"], - "requirements": ["pymyq==3.1.4"] + "requirements": ["python-myq==3.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8c2ef0e4b71..4fc9c4d6e15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1871,9 +1871,6 @@ pymonoprice==0.4 # homeassistant.components.msteams pymsteams==0.1.12 -# homeassistant.components.myq -pymyq==3.1.4 - # homeassistant.components.mysensors pymysensors==0.24.0 @@ -2148,6 +2145,9 @@ python-miio==0.5.12 # homeassistant.components.mpd python-mpd2==3.0.5 +# homeassistant.components.myq +python-myq==3.1.9 + # homeassistant.components.mystrom python-mystrom==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3da744aed73..3981f00eafd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1405,9 +1405,6 @@ pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 -# homeassistant.components.myq -pymyq==3.1.4 - # homeassistant.components.mysensors pymysensors==0.24.0 @@ -1598,6 +1595,9 @@ python-matter-server==3.7.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 +# homeassistant.components.myq +python-myq==3.1.9 + # homeassistant.components.mystrom python-mystrom==2.2.0 From 0f95de997f4b27ffcbf3899ffb2eef0be9d29e35 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 26 Sep 2023 21:52:18 +0200 Subject: [PATCH 846/984] Support cloudhooks in Withings (#100916) * Support cloudhooks in Withings * Support cloudhooks in Withings * Support cloudhooks in Withings * Remove strings --- homeassistant/components/withings/__init__.py | 136 ++++++--- .../components/withings/binary_sensor.py | 9 +- .../components/withings/config_flow.py | 36 +-- homeassistant/components/withings/const.py | 1 + .../components/withings/coordinator.py | 27 +- .../components/withings/manifest.json | 1 + .../components/withings/strings.json | 9 - tests/components/withings/__init__.py | 28 +- tests/components/withings/conftest.py | 30 +- .../withings/fixtures/empty_notify_list.json | 3 + .../withings/fixtures/notify_list.json | 4 +- .../components/withings/test_binary_sensor.py | 9 +- tests/components/withings/test_config_flow.py | 33 +-- tests/components/withings/test_init.py | 277 ++++++++++++++++-- tests/components/withings/test_sensor.py | 7 +- 15 files changed, 421 insertions(+), 189 deletions(-) create mode 100644 tests/components/withings/fixtures/empty_notify_list.json diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 47c90e2b4d1..e9721719eef 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -5,21 +5,24 @@ For more details about this platform, please refer to the documentation at from __future__ import annotations from collections.abc import Awaitable, Callable +from datetime import datetime from aiohttp.hdrs import METH_HEAD, METH_POST from aiohttp.web import Request, Response import voluptuous as vol from withings_api.common import NotifyAppli -from homeassistant.components import webhook +from homeassistant.components import cloud from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import ( - async_generate_id, - async_unregister as async_unregister_webhook, + async_generate_id as webhook_generate_id, + async_generate_url as webhook_generate_url, + async_register as webhook_register, + async_unregister as webhook_unregister, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -27,15 +30,24 @@ from homeassistant.const import ( CONF_CLIENT_SECRET, CONF_TOKEN, CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi -from .const import CONF_PROFILES, CONF_USE_WEBHOOK, CONFIG, DOMAIN, LOGGER +from .const import ( + CONF_CLOUDHOOK_URL, + CONF_PROFILES, + CONF_USE_WEBHOOK, + CONFIG, + DOMAIN, + LOGGER, +) from .coordinator import WithingsDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -100,24 +112,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - if CONF_USE_WEBHOOK not in entry.options: + if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None: new_data = entry.data.copy() - new_options = { - CONF_USE_WEBHOOK: new_data.get(CONF_USE_WEBHOOK, False), - } unique_id = str(entry.data[CONF_TOKEN]["userid"]) if CONF_WEBHOOK_ID not in new_data: - new_data[CONF_WEBHOOK_ID] = async_generate_id() + new_data[CONF_WEBHOOK_ID] = webhook_generate_id() hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, unique_id=unique_id + entry, data=new_data, unique_id=unique_id ) - if ( - use_webhook := hass.data[DOMAIN][CONFIG].get(CONF_USE_WEBHOOK) - ) is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: - new_options = entry.options.copy() - new_options |= {CONF_USE_WEBHOOK: use_webhook} - hass.config_entries.async_update_entry(entry, options=new_options) client = ConfigEntryWithingsApi( hass=hass, @@ -126,28 +129,66 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry ), ) - - use_webhooks = entry.options[CONF_USE_WEBHOOK] - coordinator = WithingsDataUpdateCoordinator(hass, client, use_webhooks) - if use_webhooks: - - @callback - def async_call_later_callback(now) -> None: - hass.async_create_task(coordinator.async_subscribe_webhooks()) - - entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) - webhook.async_register( - hass, - DOMAIN, - "Withings notify", - entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(coordinator), - ) + coordinator = WithingsDataUpdateCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + async def unregister_webhook( + call_or_event_or_dt: ServiceCall | Event | datetime | None, + ) -> None: + LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await hass.data[DOMAIN][entry.entry_id].async_unsubscribe_webhooks() + + async def register_webhook( + call_or_event_or_dt: ServiceCall | Event | datetime | None, + ) -> None: + if cloud.async_active_subscription(hass): + webhook_url = await async_cloudhook_generate_url(hass, entry) + else: + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + + if not webhook_url.startswith("https://"): + LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" + ) + return + + webhook_register( + hass, + DOMAIN, + "Withings", + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(coordinator), + ) + + await hass.data[DOMAIN][entry.entry_id].async_subscribe_webhooks(webhook_url) + LOGGER.debug("Register Withings webhook: %s", webhook_url) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + if state is cloud.CloudConnectionState.CLOUD_CONNECTED: + await register_webhook(None) + + if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: + await unregister_webhook(None) + async_call_later(hass, 30, register_webhook) + + if cloud.async_active_subscription(hass): + if cloud.async_is_connected(hass): + await register_webhook(None) + cloud.async_listen_connection_change(hass, manage_cloudhook) + + elif hass.state == CoreState.running: + await register_webhook(None) + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, register_webhook) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -156,8 +197,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Withings config entry.""" - if entry.options[CONF_USE_WEBHOOK]: - async_unregister_webhook(hass, entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) @@ -169,6 +209,30 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) +async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_url = await cloud.async_create_cloudhook( + hass, entry.data[CONF_WEBHOOK_ID] + ) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return str(entry.data[CONF_CLOUDHOOK_URL]) + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Cleanup when entry is removed.""" + if cloud.async_active_subscription(hass): + try: + LOGGER.debug( + "Removing Withings cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass + + def json_message_response(message: str, message_code: int) -> Response: """Produce common json output.""" return HomeAssistantView.json({"message": message, "code": message_code}) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index a6e19d3ef86..309ef45623f 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -47,12 +47,11 @@ async def async_setup_entry( """Set up the sensor config entry.""" coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - if coordinator.use_webhooks: - entities = [ - WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS - ] + entities = [ + WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS + ] - async_add_entities(entities) + async_add_entities(entities) class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 4dd123468a0..35a4582ae4d 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,13 +5,11 @@ from collections.abc import Mapping import logging from typing import Any -import voluptuous as vol from withings_api.common import AuthScope from homeassistant.components.webhook import async_generate_id -from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID -from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -27,14 +25,6 @@ class WithingsFlowHandler( reauth_entry: ConfigEntry | None = None - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> WithingsOptionsFlowHandler: - """Get the options flow for this handler.""" - return WithingsOptionsFlowHandler(config_entry) - @property def logger(self) -> logging.Logger: """Return logger.""" @@ -83,27 +73,9 @@ class WithingsFlowHandler( ) if self.reauth_entry.unique_id == user_id: - self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + self.hass.config_entries.async_update_entry( + self.reauth_entry, data={**self.reauth_entry.data, **data} + ) return self.async_abort(reason="reauth_successful") return self.async_abort(reason="wrong_account") - - -class WithingsOptionsFlowHandler(OptionsFlowWithConfigEntry): - """Withings Options flow handler.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Initialize form.""" - if user_input is not None: - return self.async_create_entry( - data=user_input, - ) - return self.async_show_form( - step_id="init", - data_schema=self.add_suggested_values_to_schema( - vol.Schema({vol.Required(CONF_USE_WEBHOOK): bool}), - self.options, - ), - ) diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 545c7bfcb26..6129e0c4b29 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -5,6 +5,7 @@ import logging DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" +CONF_CLOUDHOOK_URL = "cloudhook_url" DATA_MANAGER = "data_manager" diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 08d330f7d5b..128d4e39193 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -15,9 +15,7 @@ from withings_api.common import ( query_measure_groups, ) -from homeassistant.components.webhook import async_generate_url from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -72,6 +70,8 @@ WITHINGS_MEASURE_TYPE_MAP: dict[ NotifyAppli.BED_IN: Measurement.IN_BED, } +UPDATE_INTERVAL = timedelta(minutes=10) + class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): """Base coordinator.""" @@ -79,21 +79,12 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] in_bed: bool | None = None config_entry: ConfigEntry - def __init__( - self, hass: HomeAssistant, client: ConfigEntryWithingsApi, use_webhooks: bool - ) -> None: + def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: """Initialize the Withings data coordinator.""" - update_interval: timedelta | None = timedelta(minutes=10) - if use_webhooks: - update_interval = None - super().__init__(hass, LOGGER, name="Withings", update_interval=update_interval) + super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL) self._client = client - self._webhook_url = async_generate_url( - hass, self.config_entry.data[CONF_WEBHOOK_ID] - ) - self.use_webhooks = use_webhooks - async def async_subscribe_webhooks(self) -> None: + async def async_subscribe_webhooks(self, webhook_url: str) -> None: """Subscribe to webhooks.""" await self.async_unsubscribe_webhooks() @@ -102,7 +93,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] subscribed_notifications = frozenset( profile.appli for profile in current_webhooks.profiles - if profile.callbackurl == self._webhook_url + if profile.callbackurl == webhook_url ) notification_to_subscribe = ( @@ -114,14 +105,15 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] for notification in notification_to_subscribe: LOGGER.debug( "Subscribing %s for %s in %s seconds", - self._webhook_url, + webhook_url, notification, SUBSCRIBE_DELAY.total_seconds(), ) # Withings will HTTP HEAD the callback_url and needs some downtime # between each call or there is a higher chance of failure. await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) - await self._client.async_notify_subscribe(self._webhook_url, notification) + await self._client.async_notify_subscribe(webhook_url, notification) + self.update_interval = None async def async_unsubscribe_webhooks(self) -> None: """Unsubscribe to webhooks.""" @@ -140,6 +132,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] await self._client.async_notify_revoke( webhook_configuration.callbackurl, webhook_configuration.appli ) + self.update_interval = UPDATE_INTERVAL async def _async_update_data(self) -> dict[Measurement, Any]: try: diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 325205cb4d4..edc8aab83b7 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -1,6 +1,7 @@ { "domain": "withings", "name": "Withings", + "after_dependencies": ["cloud"], "codeowners": ["@vangorra", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 22718b305ec..ea925f535e3 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -22,15 +22,6 @@ "default": "Successfully authenticated with Withings." } }, - "options": { - "step": { - "init": { - "data": { - "use_webhook": "Use webhooks" - } - } - } - }, "entity": { "binary_sensor": { "in_bed": { diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index e6fb24244d6..459deaae4c5 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -6,10 +6,8 @@ from urllib.parse import urlparse from aiohttp.test_utils import TestClient from homeassistant.components.webhook import async_generate_url -from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -42,26 +40,16 @@ async def call_webhook( return WebhookResponse(message=data["message"], message_code=data["code"]) -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, enable_webhooks: bool = True +) -> None: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) - await async_process_ha_core_config( - hass, - {"external_url": "http://example.local:8123"}, - ) + if enable_webhooks: + await async_process_ha_core_config( + hass, + {"external_url": "https://example.local:8123"}, + ) await hass.config_entries.async_setup(config_entry.entry_id) - - -async def enable_webhooks(hass: HomeAssistant) -> None: - """Enable webhooks.""" - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_USE_WEBHOOK: True, - } - }, - ) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index e7777d470a5..3fc2a3c6461 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -79,8 +79,29 @@ def webhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "profile": TITLE, "webhook_id": WEBHOOK_ID, }, - options={ - "use_webhook": True, + ) + + +@pytest.fixture +def cloudhook_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=str(USER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": str(USER_ID), + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "webhook_id": WEBHOOK_ID, + "cloudhook_url": "https://hooks.nabu.casa/ABCD", }, ) @@ -105,9 +126,6 @@ def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "profile": TITLE, "webhook_id": WEBHOOK_ID, }, - options={ - "use_webhook": False, - }, ) @@ -136,7 +154,7 @@ def mock_withings(): yield mock -@pytest.fixture(name="disable_webhook_delay") +@pytest.fixture(name="disable_webhook_delay", autouse=True) def disable_webhook_delay(): """Disable webhook delay.""" diff --git a/tests/components/withings/fixtures/empty_notify_list.json b/tests/components/withings/fixtures/empty_notify_list.json new file mode 100644 index 00000000000..c905c95e4cb --- /dev/null +++ b/tests/components/withings/fixtures/empty_notify_list.json @@ -0,0 +1,3 @@ +{ + "profiles": [] +} diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json index bc696db583a..5b368a5c979 100644 --- a/tests/components/withings/fixtures/notify_list.json +++ b/tests/components/withings/fixtures/notify_list.json @@ -8,13 +8,13 @@ }, { "appli": 50, - "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", "expires": 2147483647, "comment": null }, { "appli": 51, - "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", "expires": 2147483647, "comment": null } diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 8e641925d60..d258986bdaf 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -8,7 +8,7 @@ from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from . import call_webhook, enable_webhooks, setup_integration +from . import call_webhook, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry @@ -18,12 +18,10 @@ from tests.typing import ClientSessionGenerator async def test_binary_sensor( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() @@ -56,18 +54,17 @@ async def test_binary_sensor( async def test_polling_binary_sensor( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, polling_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - await setup_integration(hass, polling_config_entry) + await setup_integration(hass, polling_config_entry, False) client = await hass_client_no_auth() entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id) is None + assert hass.states.get(entity_id).state == STATE_UNKNOWN with pytest.raises(ClientResponseError): await call_webhook( diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 1fc26824d45..36edffcc346 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for config flow.""" from unittest.mock import AsyncMock, patch -from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN +from homeassistant.components.withings.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -84,7 +84,6 @@ async def test_config_non_unique_profile( current_request_with_host: None, withings: AsyncMock, polling_config_entry: MockConfigEntry, - disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" @@ -138,7 +137,6 @@ async def test_config_reauth_profile( aioclient_mock: AiohttpClientMocker, polling_config_entry: MockConfigEntry, withings: AsyncMock, - disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth an existing profile reauthenticates the config entry.""" @@ -201,7 +199,6 @@ async def test_config_reauth_wrong_account( aioclient_mock: AiohttpClientMocker, polling_config_entry: MockConfigEntry, withings: AsyncMock, - disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth with wrong account.""" @@ -256,31 +253,3 @@ async def test_config_reauth_wrong_account( assert result assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_account" - - -async def test_options_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - polling_config_entry: MockConfigEntry, - withings: AsyncMock, - disable_webhook_delay, - current_request_with_host, -) -> None: - """Test options flow.""" - await setup_integration(hass, polling_config_entry) - - result = await hass.config_entries.options.async_init(polling_config_entry.entry_id) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_USE_WEBHOOK: True}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_USE_WEBHOOK: True} diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 6e5c10390ff..353dcee8a7c 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,26 +1,43 @@ """Tests for the Withings component.""" from datetime import timedelta from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import urlparse from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from withings_api import NotifyListResponse from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException from homeassistant import config_entries +from homeassistant.components.cloud import ( + SIGNAL_CLOUD_CONNECTION_STATE, + CloudConnectionState, + CloudNotAvailable, +) from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, async_setup from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt as dt_util -from . import call_webhook, enable_webhooks, setup_integration +from . import call_webhook, setup_integration from .conftest import USER_ID, WEBHOOK_ID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) +from tests.components.cloud import mock_cloud from tests.typing import ClientSessionGenerator @@ -108,12 +125,10 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None: async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) await hass_client_no_auth() await hass.async_block_till_done() @@ -122,7 +137,7 @@ async def test_data_manager_webhook_subscription( assert withings.async_notify_subscribe.call_count == 4 - webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + webhook_url = "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) withings.async_notify_subscribe.assert_any_call( @@ -138,7 +153,6 @@ async def test_data_manager_webhook_subscription( async def test_webhook_subscription_polling_config( hass: HomeAssistant, withings: AsyncMock, - disable_webhook_delay, polling_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, @@ -169,10 +183,8 @@ async def test_requests( webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, method: str, - disable_webhook_delay, ) -> None: """Test we handle request methods Withings sends.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) @@ -189,10 +201,8 @@ async def test_webhooks_request_data( withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, - disable_webhook_delay, ) -> None: """Test calling a webhook requests data.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() @@ -207,6 +217,35 @@ async def test_webhooks_request_data( assert withings.async_measure_get_meas.call_count == 2 +async def test_delayed_startup( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test delayed start up.""" + hass.state = CoreState.not_running + await setup_integration(hass, webhook_config_entry) + + withings.async_notify_subscribe.assert_not_called() + client = await hass_client_no_auth() + + assert withings.async_measure_get_meas.call_count == 1 + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + client, + ) + assert withings.async_measure_get_meas.call_count == 2 + + @pytest.mark.parametrize( "error", [ @@ -221,7 +260,7 @@ async def test_triggering_reauth( error: Exception, ) -> None: """Test triggering reauth.""" - await setup_integration(hass, polling_config_entry) + await setup_integration(hass, polling_config_entry, False) withings.async_measure_get_meas.side_effect = error future = dt_util.utcnow() + timedelta(minutes=10) @@ -275,9 +314,211 @@ async def test_config_flow_upgrade( assert entry.unique_id == "123" assert entry.data["token"]["userid"] == 123 assert CONF_WEBHOOK_ID in entry.data - assert entry.options == { - "use_webhook": False, - } + + +async def test_setup_with_cloudhook( + hass: HomeAssistant, cloudhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test if set up with active cloud subscription and cloud hook.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, cloudhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data["cloudhook_url"] + == "https://hooks.nabu.casa/ABCD" + ) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + fake_create_cloudhook.assert_not_called() + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_removing_entry_with_cloud_unavailable( + hass: HomeAssistant, cloudhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test handling cloud unavailable when deleting entry.""" + + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook", + side_effect=CloudNotAvailable(), + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, cloudhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_setup_with_cloud( + hass: HomeAssistant, webhook_config_entry: MockConfigEntry, withings: AsyncMock +) -> None: + """Test if set up with active cloud subscription.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ) as fake_create_cloudhook, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ) as fake_delete_cloudhook, patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + assert hass.components.cloud.async_is_connected() is True + fake_create_cloudhook.assert_called_once() + + assert ( + hass.config_entries.async_entries("withings")[0].data["cloudhook_url"] + == "https://hooks.nabu.casa/ABCD" + ) + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) + + for config_entry in hass.config_entries.async_entries("withings"): + await hass.config_entries.async_remove(config_entry.entry_id) + fake_delete_cloudhook.assert_called_once() + + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_setup_without_https( + hass: HomeAssistant, + webhook_config_entry: MockConfigEntry, + withings: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if set up with cloud link and without https.""" + hass.config.components.add("cloud") + with patch( + "homeassistant.helpers.network.get_url", + return_value="http://example.nabu.casa", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ) as mock_async_generate_url: + mock_async_generate_url.return_value = "http://example.com" + await setup_integration(hass, webhook_config_entry) + + await hass.async_block_till_done() + mock_async_generate_url.assert_called_once() + + assert "https and port 443 is required to register the webhook" in caplog.text + + +async def test_cloud_disconnect( + hass: HomeAssistant, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test disconnecting from the cloud.""" + await mock_cloud(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + assert hass.components.cloud.async_active_subscription() is True + assert hass.components.cloud.async_is_connected() is True + + await hass.async_block_till_done() + + withings.async_notify_list.return_value = NotifyListResponse( + **load_json_object_fixture("withings/empty_notify_list.json") + ) + + assert withings.async_notify_subscribe.call_count == 6 + + async_dispatcher_send( + hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_DISCONNECTED + ) + await hass.async_block_till_done() + + assert withings.async_notify_revoke.call_count == 3 + + async_dispatcher_send( + hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED + ) + await hass.async_block_till_done() + + assert withings.async_notify_subscribe.call_count == 12 @pytest.mark.parametrize( @@ -300,13 +541,11 @@ async def test_webhook_post( withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, - disable_webhook_delay, body: dict[str, Any], expected_code: int, current_request_with_host: None, ) -> None: """Test webhook callback.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index b0df6e4c3c2..fe640e315a0 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from . import call_webhook, enable_webhooks, setup_integration +from . import call_webhook, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -95,11 +95,9 @@ async def test_sensor_default_enabled_entities( hass: HomeAssistant, withings: AsyncMock, webhook_config_entry: MockConfigEntry, - disable_webhook_delay, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" - await enable_webhooks(hass) await setup_integration(hass, webhook_config_entry) entity_registry: EntityRegistry = er.async_get(hass) @@ -137,7 +135,6 @@ async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, withings: AsyncMock, - disable_webhook_delay, polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" @@ -156,7 +153,7 @@ async def test_update_failed( freezer: FrozenDateTimeFactory, ) -> None: """Test all entities.""" - await setup_integration(hass, polling_config_entry) + await setup_integration(hass, polling_config_entry, False) withings.async_measure_get_meas.side_effect = Exception freezer.tick(timedelta(minutes=10)) From 8ec11910af8ac8eeea1bc27982b8cf1129ef6878 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 26 Sep 2023 22:21:27 +0200 Subject: [PATCH 847/984] Allow discovery config update mqtt update entities (#100957) --- homeassistant/components/mqtt/update.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index cf3237c1b1c..45cca7279f9 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -114,24 +114,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): _default_name = DEFAULT_NAME _entity_id_format = update.ENTITY_ID_FORMAT - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, - ) -> None: - """Initialize the MQTT update.""" - self._config = config - self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) - self._attr_release_summary = self._config.get(CONF_RELEASE_SUMMARY) - self._attr_release_url = self._config.get(CONF_RELEASE_URL) - self._attr_title = self._config.get(CONF_TITLE) - self._entity_picture: str | None = self._config.get(CONF_ENTITY_PICTURE) - - UpdateEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + _entity_picture: str | None @property def entity_picture(self) -> str | None: @@ -148,6 +131,11 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + self._attr_release_summary = self._config.get(CONF_RELEASE_SUMMARY) + self._attr_release_url = self._config.get(CONF_RELEASE_URL) + self._attr_title = self._config.get(CONF_TITLE) + self._entity_picture: str | None = self._config.get(CONF_ENTITY_PICTURE) self._templates = { CONF_VALUE_TEMPLATE: MqttValueTemplate( config.get(CONF_VALUE_TEMPLATE), From d387308f3c0379b3b72b1490c1d611510ad4085f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Sep 2023 22:50:46 +0200 Subject: [PATCH 848/984] Move motion blinds coordinator to its own file (#100952) --- .coveragerc | 1 + .../components/motion_blinds/__init__.py | 81 +--------------- .../components/motion_blinds/coordinator.py | 94 +++++++++++++++++++ .../components/motion_blinds/entity.py | 2 +- 4 files changed, 99 insertions(+), 79 deletions(-) create mode 100644 homeassistant/components/motion_blinds/coordinator.py diff --git a/.coveragerc b/.coveragerc index b2beacfe5a5..6d625e73939 100644 --- a/.coveragerc +++ b/.coveragerc @@ -767,6 +767,7 @@ omit = homeassistant/components/moehlenhoff_alpha2/climate.py homeassistant/components/moehlenhoff_alpha2/sensor.py homeassistant/components/motion_blinds/__init__.py + homeassistant/components/motion_blinds/coordinator.py homeassistant/components/motion_blinds/cover.py homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 188f3a784ac..45b1e42c8bb 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -2,19 +2,16 @@ import asyncio from datetime import timedelta import logging -from socket import timeout -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from motionblinds import AsyncMotionMulticast, ParseException +from motionblinds import AsyncMotionMulticast from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( - ATTR_AVAILABLE, CONF_INTERFACE, CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, @@ -28,85 +25,13 @@ from .const import ( KEY_UNSUB_STOP, PLATFORMS, UPDATE_INTERVAL, - UPDATE_INTERVAL_FAST, ) +from .coordinator import DataUpdateCoordinatorMotionBlinds from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) -class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): - """Class to manage fetching data from single endpoint.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - coordinator_info: dict[str, Any], - *, - name: str, - update_interval: timedelta, - ) -> None: - """Initialize global data updater.""" - super().__init__( - hass, - logger, - name=name, - update_interval=update_interval, - ) - - self.api_lock = coordinator_info[KEY_API_LOCK] - self._gateway = coordinator_info[KEY_GATEWAY] - self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] - - def update_gateway(self): - """Fetch data from gateway.""" - try: - self._gateway.Update() - except (timeout, ParseException): - # let the error be logged and handled by the motionblinds library - return {ATTR_AVAILABLE: False} - - return {ATTR_AVAILABLE: True} - - def update_blind(self, blind): - """Fetch data from a blind.""" - try: - if self._wait_for_push: - blind.Update() - else: - blind.Update_trigger() - except (timeout, ParseException): - # let the error be logged and handled by the motionblinds library - return {ATTR_AVAILABLE: False} - - return {ATTR_AVAILABLE: True} - - async def _async_update_data(self): - """Fetch the latest data from the gateway and blinds.""" - data = {} - - async with self.api_lock: - data[KEY_GATEWAY] = await self.hass.async_add_executor_job( - self.update_gateway - ) - - for blind in self._gateway.device_list.values(): - await asyncio.sleep(1.5) - async with self.api_lock: - data[blind.mac] = await self.hass.async_add_executor_job( - self.update_blind, blind - ) - - all_available = all(device[ATTR_AVAILABLE] for device in data.values()) - if all_available: - self.update_interval = timedelta(seconds=UPDATE_INTERVAL) - else: - self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST) - - return data - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the motion_blinds components from a config entry.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py new file mode 100644 index 00000000000..cfc7d319b38 --- /dev/null +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -0,0 +1,94 @@ +"""DataUpdateCoordinator for motion blinds integration.""" +import asyncio +from datetime import timedelta +import logging +from socket import timeout +from typing import Any + +from motionblinds import ParseException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + ATTR_AVAILABLE, + CONF_WAIT_FOR_PUSH, + KEY_API_LOCK, + KEY_GATEWAY, + UPDATE_INTERVAL, + UPDATE_INTERVAL_FAST, +) + +_LOGGER = logging.getLogger(__name__) + + +class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): + """Class to manage fetching data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + coordinator_info: dict[str, Any], + *, + name: str, + update_interval: timedelta, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + ) + + self.api_lock = coordinator_info[KEY_API_LOCK] + self._gateway = coordinator_info[KEY_GATEWAY] + self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] + + def update_gateway(self): + """Fetch data from gateway.""" + try: + self._gateway.Update() + except (timeout, ParseException): + # let the error be logged and handled by the motionblinds library + return {ATTR_AVAILABLE: False} + + return {ATTR_AVAILABLE: True} + + def update_blind(self, blind): + """Fetch data from a blind.""" + try: + if self._wait_for_push: + blind.Update() + else: + blind.Update_trigger() + except (timeout, ParseException): + # let the error be logged and handled by the motionblinds library + return {ATTR_AVAILABLE: False} + + return {ATTR_AVAILABLE: True} + + async def _async_update_data(self): + """Fetch the latest data from the gateway and blinds.""" + data = {} + + async with self.api_lock: + data[KEY_GATEWAY] = await self.hass.async_add_executor_job( + self.update_gateway + ) + + for blind in self._gateway.device_list.values(): + await asyncio.sleep(1.5) + async with self.api_lock: + data[blind.mac] = await self.hass.async_add_executor_job( + self.update_blind, blind + ) + + all_available = all(device[ATTR_AVAILABLE] for device in data.values()) + if all_available: + self.update_interval = timedelta(seconds=UPDATE_INTERVAL) + else: + self.update_interval = timedelta(seconds=UPDATE_INTERVAL_FAST) + + return data diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index 8f3ac05228d..56eccb04eae 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -8,7 +8,6 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DataUpdateCoordinatorMotionBlinds from .const import ( ATTR_AVAILABLE, DEFAULT_GATEWAY_NAME, @@ -16,6 +15,7 @@ from .const import ( KEY_GATEWAY, MANUFACTURER, ) +from .coordinator import DataUpdateCoordinatorMotionBlinds from .gateway import device_name From 59a26010ba1cea7e932f9eb9b43a00f3e7130d39 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 26 Sep 2023 23:03:11 +0200 Subject: [PATCH 849/984] Cleanup redundant mqtt entity constructors (#100939) * Remove redundant mqtt entity constructors * Remove unrelated change * Follow up comment * Revert changes to mqtt update platform --------- Co-authored-by: J. Nick Koston --- .../components/mqtt/alarm_control_panel.py | 10 ----- .../components/mqtt/binary_sensor.py | 15 +------ homeassistant/components/mqtt/button.py | 10 ----- homeassistant/components/mqtt/camera.py | 3 +- homeassistant/components/mqtt/climate.py | 39 ++++--------------- .../components/mqtt/device_tracker.py | 12 +----- homeassistant/components/mqtt/event.py | 10 ----- homeassistant/components/mqtt/fan.py | 16 ++------ homeassistant/components/mqtt/humidifier.py | 13 +------ homeassistant/components/mqtt/lawn_mower.py | 11 ------ .../components/mqtt/light/schema_basic.py | 10 ----- .../components/mqtt/light/schema_json.py | 12 +----- .../components/mqtt/light/schema_template.py | 10 ----- homeassistant/components/mqtt/lock.py | 10 ----- homeassistant/components/mqtt/number.py | 11 ------ homeassistant/components/mqtt/scene.py | 10 ----- homeassistant/components/mqtt/select.py | 13 +------ homeassistant/components/mqtt/sensor.py | 12 +----- homeassistant/components/mqtt/siren.py | 10 ----- homeassistant/components/mqtt/switch.py | 11 ------ homeassistant/components/mqtt/text.py | 12 +----- .../components/mqtt/vacuum/schema_legacy.py | 30 ++++---------- homeassistant/components/mqtt/water_heater.py | 12 ------ 23 files changed, 27 insertions(+), 275 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 2bfaa7d1913..3600d9663dd 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -158,16 +158,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): _entity_id_format = alarm.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_ALARM_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Init the MQTT Alarm Control Panel.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 505305cad3e..c0f4cc7786e 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -98,22 +98,11 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): """Representation a binary sensor that is updated by MQTT.""" _default_name = DEFAULT_NAME + _delay_listener: CALLBACK_TYPE | None = None _entity_id_format = binary_sensor.ENTITY_ID_FORMAT _expired: bool | None _expire_after: int | None - - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT binary sensor.""" - self._expiration_trigger: CALLBACK_TYPE | None = None - self._delay_listener: CALLBACK_TYPE | None = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + _expiration_trigger: CALLBACK_TYPE | None = None async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 9b3b04a54f5..47ac12386f7 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -73,16 +73,6 @@ class MqttButton(MqttEntity, ButtonEntity): _default_name = DEFAULT_NAME _entity_id_format = button.ENTITY_ID_FORMAT - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT button.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index edddd0f2239..c8402e501b0 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -84,6 +84,7 @@ class MqttCamera(MqttEntity, Camera): _default_name = DEFAULT_NAME _entity_id_format: str = camera.ENTITY_ID_FORMAT _attributes_extra_blocked: frozenset[str] = MQTT_CAMERA_ATTRIBUTES_BLOCKED + _last_image: bytes | None = None def __init__( self, @@ -93,8 +94,6 @@ class MqttCamera(MqttEntity, Camera): discovery_data: DiscoveryInfoType | None, ) -> None: """Initialize the MQTT Camera.""" - self._last_image: bytes | None = None - Camera.__init__(self) MqttEntity.__init__(self, hass, config, config_entry, discovery_data) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index dfd5d70dca6..77f28e1b5ca 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -424,28 +424,16 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): climate and water_heater platforms. """ - _attr_target_temperature_low: float | None - _attr_target_temperature_high: float | None + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None + _feature_preset_mode: bool = False _optimistic: bool _topic: dict[str, Any] _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the temperature controlled device.""" - self._attr_target_temperature_low = None - self._attr_target_temperature_high = None - self._feature_preset_mode = False - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - def add_subscription( self, topics: dict[str, dict[str, Any]], @@ -619,27 +607,14 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): """Representation of an MQTT climate device.""" + _attr_fan_mode: str | None = None + _attr_hvac_mode: HVACMode | None = None + _attr_is_aux_heat: bool | None = None + _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the climate device.""" - self._attr_fan_mode = None - self._attr_hvac_action = None - self._attr_hvac_mode = None - self._attr_is_aux_heat = None - self._attr_swing_mode = None - MqttTemperatureControlEntity.__init__( - self, hass, config, config_entry, discovery_data - ) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index f99eab4d58f..2270f2b4031 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -107,19 +107,9 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): _default_name = None _entity_id_format = device_tracker.ENTITY_ID_FORMAT + _location_name: str | None = None _value_template: Callable[..., ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the tracker.""" - self._location_name: str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 6fe39b5e899..c345655eea5 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -108,16 +108,6 @@ class MqttEvent(MqttEntity, EventEntity): _attributes_extra_blocked = MQTT_EVENT_ATTRIBUTES_BLOCKED _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the sensor.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 5375fa5afc2..0aad3a6afc0 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -220,6 +220,9 @@ async def _async_setup_entity( class MqttFan(MqttEntity, FanEntity): """A MQTT fan component.""" + _attr_percentage: int | None = None + _attr_preset_mode: str | None = None + _default_name = DEFAULT_NAME _entity_id_format = fan.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_FAN_ATTRIBUTES_BLOCKED @@ -237,19 +240,6 @@ class MqttFan(MqttEntity, FanEntity): _payload: dict[str, Any] _speed_range: tuple[int, int] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT fan.""" - self._attr_percentage = None - self._attr_preset_mode = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 1742a768ffb..05929ee904a 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -212,6 +212,7 @@ async def _async_setup_entity( class MqttHumidifier(MqttEntity, HumidifierEntity): """A MQTT humidifier component.""" + _attr_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = humidifier.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED @@ -224,18 +225,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): _payload: dict[str, str] _topic: dict[str, Any] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT humidifier.""" - self._attr_mode = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index de2f7d47a46..68c7eda16ea 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -119,17 +119,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): _command_topics: dict[str, str] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT lawn mower.""" - LawnMowerEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index ab8d9921161..65c05501658 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -264,16 +264,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): _optimistic_rgbww_color: bool _optimistic_xy_color: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize MQTT light.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index ee7e78b0028..462280b1516 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -184,21 +184,11 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED + _fixed_color_mode: ColorMode | str | None = None _flash_times: dict[str, int | None] _topic: dict[str, str | None] _optimistic: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize MQTT JSON light.""" - self._fixed_color_mode: ColorMode | str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index ecbcdcd18d7..a225ce43efa 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -138,16 +138,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): _fixed_color_mode: ColorMode | str | None _topics: dict[str, str | None] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize a MQTT Template light.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index cdaa00e3d63..b565e8a4b57 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -142,16 +142,6 @@ class MqttLock(MqttEntity, LockEntity): ] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the lock.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 5ca0340ec30..231da95ffb0 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -146,17 +146,6 @@ class MqttNumber(MqttEntity, RestoreNumber): _command_template: Callable[[PublishPayloadType], PublishPayloadType] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT Number.""" - RestoreNumber.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index fd876976fe6..9e7c280cbc0 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -69,16 +69,6 @@ class MqttScene( _default_name = DEFAULT_NAME _entity_id_format = scene.DOMAIN + ".{}" - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT scene.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 7982e075567..03cd529fdd0 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -93,6 +93,7 @@ async def _async_setup_entity( class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): """representation of an MQTT select.""" + _attr_current_option: str | None = None _default_name = DEFAULT_NAME _entity_id_format = select.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_SELECT_ATTRIBUTES_BLOCKED @@ -100,18 +101,6 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] _optimistic: bool = False - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT select.""" - self._attr_current_option = None - SelectEntity.__init__(self) - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 278e70a9737..05db22a8e62 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -130,22 +130,12 @@ class MqttSensor(MqttEntity, RestoreSensor): _entity_id_format = ENTITY_ID_FORMAT _attr_last_reset: datetime | None = None _attributes_extra_blocked = MQTT_SENSOR_ATTRIBUTES_BLOCKED + _expiration_trigger: CALLBACK_TYPE | None = None _expire_after: int | None _expired: bool | None _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] _last_reset_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the sensor.""" - self._expiration_trigger: CALLBACK_TYPE | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" last_state: State | None diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 77880c9a9ed..7978776a089 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -155,16 +155,6 @@ class MqttSiren(MqttEntity, SirenEntity): _state_off: str _optimistic: bool - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT siren.""" - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 125998e5955..d4e8f2609d9 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -100,17 +100,6 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): _state_off: str _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the MQTT switch.""" - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index e6755c653f3..630951f171e 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -128,6 +128,7 @@ async def _async_setup_entity( class MqttTextEntity(MqttEntity, TextEntity): """Representation of the MQTT text entity.""" + _attr_native_value: str | None = None _attributes_extra_blocked = MQTT_TEXT_ATTRIBUTES_BLOCKED _default_name = DEFAULT_NAME _entity_id_format = text.ENTITY_ID_FORMAT @@ -137,17 +138,6 @@ class MqttTextEntity(MqttEntity, TextEntity): _command_template: Callable[[PublishPayloadType], PublishPayloadType] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, - ) -> None: - """Initialize MQTT text entity.""" - self._attr_native_value = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 478a91baaba..aee71cc6690 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -215,12 +215,17 @@ async def async_setup_entity_legacy( class MqttVacuum(MqttEntity, VacuumEntity): """Representation of a MQTT-controlled legacy vacuum.""" + _attr_battery_level = 0 + _attr_is_on = False + _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED + _charging: bool = False + _cleaning: bool = False + _command_topic: str | None + _docked: bool = False _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT - _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED - - _command_topic: str | None _encoding: str | None + _error: str | None = None _qos: bool _retain: bool _payloads: dict[str, str] @@ -231,25 +236,6 @@ class MqttVacuum(MqttEntity, VacuumEntity): str, Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] ] - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the vacuum.""" - self._attr_battery_level = 0 - self._attr_is_on = False - self._attr_fan_speed = "unknown" - - self._charging = False - self._cleaning = False - self._docked = False - self._error: str | None = None - - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index f35e7f8b0ea..9a9326d6d07 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -194,18 +194,6 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _entity_id_format = water_heater.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED - def __init__( - self, - hass: HomeAssistant, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, - ) -> None: - """Initialize the water heater device.""" - MqttTemperatureControlEntity.__init__( - self, hass, config, config_entry, discovery_data - ) - @staticmethod def config_schema() -> vol.Schema: """Return the config schema.""" From 176f5dc2d6b5eb3039b6283a5c212313beb68f1f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 26 Sep 2023 17:05:54 -0400 Subject: [PATCH 850/984] Bump zwave-js-server-python to 0.52.0 (#100833) * Bump zwave-js-server-python to 0.52.0 * remove old function * fix tests --- homeassistant/components/zwave_js/api.py | 55 ++++--- homeassistant/components/zwave_js/helpers.py | 1 - .../components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/update.py | 11 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 141 +++++++++--------- .../zwave_js/test_device_trigger.py | 2 + tests/components/zwave_js/test_events.py | 5 + tests/components/zwave_js/test_update.py | 48 +++++- 10 files changed, 169 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index d93745f7a66..314b53456aa 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -411,15 +411,15 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_remove_node) websocket_api.async_register_command(hass, websocket_remove_failed_node) websocket_api.async_register_command(hass, websocket_replace_failed_node) - websocket_api.async_register_command(hass, websocket_begin_healing_network) + websocket_api.async_register_command(hass, websocket_begin_rebuilding_routes) websocket_api.async_register_command( - hass, websocket_subscribe_heal_network_progress + hass, websocket_subscribe_rebuild_routes_progress ) - websocket_api.async_register_command(hass, websocket_stop_healing_network) + websocket_api.async_register_command(hass, websocket_stop_rebuilding_routes) websocket_api.async_register_command(hass, websocket_refresh_node_info) websocket_api.async_register_command(hass, websocket_refresh_node_values) websocket_api.async_register_command(hass, websocket_refresh_node_cc_values) - websocket_api.async_register_command(hass, websocket_heal_node) + websocket_api.async_register_command(hass, websocket_rebuild_node_routes) websocket_api.async_register_command(hass, websocket_set_config_parameter) websocket_api.async_register_command(hass, websocket_get_config_parameters) websocket_api.async_register_command(hass, websocket_subscribe_log_updates) @@ -511,7 +511,7 @@ async def websocket_network_status( "supported_function_types": controller.supported_function_types, "suc_node_id": controller.suc_node_id, "supports_timers": controller.supports_timers, - "is_heal_network_active": controller.is_heal_network_active, + "is_rebuilding_routes": controller.is_rebuilding_routes, "inclusion_state": controller.inclusion_state, "rf_region": controller.rf_region, "status": controller.status, @@ -1379,14 +1379,14 @@ async def websocket_remove_failed_node( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/begin_healing_network", + vol.Required(TYPE): "zwave_js/begin_rebuilding_routes", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_entry -async def websocket_begin_healing_network( +async def websocket_begin_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1394,10 +1394,10 @@ async def websocket_begin_healing_network( client: Client, driver: Driver, ) -> None: - """Begin healing the Z-Wave network.""" + """Begin rebuilding Z-Wave routes.""" controller = driver.controller - result = await controller.async_begin_healing_network() + result = await controller.async_begin_rebuilding_routes() connection.send_result( msg[ID], result, @@ -1407,13 +1407,13 @@ async def websocket_begin_healing_network( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/subscribe_heal_network_progress", + vol.Required(TYPE): "zwave_js/subscribe_rebuild_routes_progress", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_get_entry -async def websocket_subscribe_heal_network_progress( +async def websocket_subscribe_rebuild_routes_progress( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1421,7 +1421,7 @@ async def websocket_subscribe_heal_network_progress( client: Client, driver: Driver, ) -> None: - """Subscribe to heal Z-Wave network status updates.""" + """Subscribe to rebuild Z-Wave routes status updates.""" controller = driver.controller @callback @@ -1434,30 +1434,39 @@ async def websocket_subscribe_heal_network_progress( def forward_event(key: str, event: dict) -> None: connection.send_message( websocket_api.event_message( - msg[ID], {"event": event["event"], "heal_node_status": event[key]} + msg[ID], {"event": event["event"], "rebuild_routes_status": event[key]} ) ) connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [ - controller.on("heal network progress", partial(forward_event, "progress")), - controller.on("heal network done", partial(forward_event, "result")), + controller.on("rebuild routes progress", partial(forward_event, "progress")), + controller.on("rebuild routes done", partial(forward_event, "result")), ] - connection.send_result(msg[ID], controller.heal_network_progress) + if controller.rebuild_routes_progress: + connection.send_result( + msg[ID], + { + node.node_id: status + for node, status in controller.rebuild_routes_progress.items() + }, + ) + else: + connection.send_result(msg[ID], None) @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/stop_healing_network", + vol.Required(TYPE): "zwave_js/stop_rebuilding_routes", vol.Required(ENTRY_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_entry -async def websocket_stop_healing_network( +async def websocket_stop_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1465,9 +1474,9 @@ async def websocket_stop_healing_network( client: Client, driver: Driver, ) -> None: - """Stop healing the Z-Wave network.""" + """Stop rebuilding Z-Wave routes.""" controller = driver.controller - result = await controller.async_stop_healing_network() + result = await controller.async_stop_rebuilding_routes() connection.send_result( msg[ID], result, @@ -1477,14 +1486,14 @@ async def websocket_stop_healing_network( @websocket_api.require_admin @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/heal_node", + vol.Required(TYPE): "zwave_js/rebuild_node_routes", vol.Required(DEVICE_ID): str, } ) @websocket_api.async_response @async_handle_failed_command @async_get_node -async def websocket_heal_node( +async def websocket_rebuild_node_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], @@ -1495,7 +1504,7 @@ async def websocket_heal_node( assert driver is not None # The node comes from the driver instance. controller = driver.controller - result = await controller.async_heal_node(node) + result = await controller.async_rebuild_node_routes(node) connection.send_result( msg[ID], result, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 3b1faa40fa8..a163b8636e8 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -125,7 +125,6 @@ def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: async def async_enable_statistics(driver: Driver) -> None: """Enable statistics on the driver.""" await driver.async_enable_statistics("Home Assistant", HA_VERSION) - await driver.async_enable_error_reporting() @callback diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index cfb2c239d8e..4c697a9c2b7 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.51.3"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 5b7c157552a..3dedd8bf370 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -62,7 +62,12 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): @classmethod def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: """Initialize the extra data from a dict.""" - if not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]): + # If there was no firmware info stored, or if it's stale info, we don't restore + # anything. + if ( + not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]) + or "normalizedVersion" not in firmware_dict + ): return cls(None) return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) @@ -267,9 +272,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) try: - await self.driver.controller.async_firmware_update_ota( - self.node, firmware.files - ) + await self.driver.controller.async_firmware_update_ota(self.node, firmware) except BaseZwaveJSServerError as err: self._unsub_firmware_events_and_reset_progress() raise HomeAssistantError(err) from err diff --git a/requirements_all.txt b/requirements_all.txt index 4fc9c4d6e15..9ecfb48fba0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ zigpy==0.57.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.3 +zwave-js-server-python==0.52.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3981f00eafd..4a6181d4d4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2096,7 +2096,7 @@ zigpy-znp==0.11.4 zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.3 +zwave-js-server-python==0.52.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 02ed507cabe..0c0b3c7e132 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -906,7 +906,7 @@ async def test_add_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1179,7 +1179,7 @@ async def test_provision_smart_start_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1283,7 +1283,7 @@ async def test_unprovision_smart_start_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1355,7 +1355,7 @@ async def test_get_provisioning_entries( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1450,7 +1450,7 @@ async def test_parse_qr_code_string( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1517,7 +1517,7 @@ async def test_try_parse_dsk_from_qr_code_string( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1599,7 +1599,7 @@ async def test_cancel_inclusion_exclusion( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test FailedZWaveCommand is caught with patch( @@ -1617,7 +1617,7 @@ async def test_cancel_inclusion_exclusion( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -1736,7 +1736,7 @@ async def test_remove_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2081,7 +2081,7 @@ async def test_replace_failed_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2132,7 +2132,7 @@ async def test_remove_failed_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" await ws_client.send_json( { @@ -2187,13 +2187,13 @@ async def test_remove_failed_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_begin_healing_network( +async def test_begin_rebuilding_routes( hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the begin_healing_network websocket command.""" + """Test the begin_rebuilding_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2202,7 +2202,7 @@ async def test_begin_healing_network( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2213,13 +2213,13 @@ async def test_begin_healing_network( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_begin_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_rebuilding_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2227,7 +2227,7 @@ async def test_begin_healing_network( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2236,7 +2236,7 @@ async def test_begin_healing_network( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/begin_healing_network", + TYPE: "zwave_js/begin_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2246,17 +2246,21 @@ async def test_begin_healing_network( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_subscribe_heal_network_progress( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +async def test_subscribe_rebuild_routes_progress( + hass: HomeAssistant, + integration, + client, + nortek_thermostat, + hass_ws_client: WebSocketGenerator, ) -> None: - """Test the subscribe_heal_network_progress command.""" + """Test the subscribe_rebuild_routes_progress command.""" entry = integration ws_client = await hass_ws_client(hass) await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2265,19 +2269,19 @@ async def test_subscribe_heal_network_progress( assert msg["success"] assert msg["result"] is None - # Fire heal network progress + # Fire rebuild routes progress event = Event( - "heal network progress", + "rebuild routes progress", { "source": "controller", - "event": "heal network progress", + "event": "rebuild routes progress", "progress": {67: "pending"}, }, ) client.driver.controller.receive_event(event) msg = await ws_client.receive_json() - assert msg["event"]["event"] == "heal network progress" - assert msg["event"]["heal_node_status"] == {"67": "pending"} + assert msg["event"]["event"] == "rebuild routes progress" + assert msg["event"]["rebuild_routes_status"] == {"67": "pending"} # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2286,7 +2290,7 @@ async def test_subscribe_heal_network_progress( await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2296,21 +2300,25 @@ async def test_subscribe_heal_network_progress( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_subscribe_heal_network_progress_initial_value( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +async def test_subscribe_rebuild_routes_progress_initial_value( + hass: HomeAssistant, + integration, + client, + nortek_thermostat, + hass_ws_client: WebSocketGenerator, ) -> None: - """Test subscribe_heal_network_progress command when heal network in progress.""" + """Test subscribe_rebuild_routes_progress command when rebuild routes in progress.""" entry = integration ws_client = await hass_ws_client(hass) - assert not client.driver.controller.heal_network_progress + assert not client.driver.controller.rebuild_routes_progress - # Fire heal network progress before sending heal network progress command + # Fire rebuild routes progress before sending rebuild routes progress command event = Event( - "heal network progress", + "rebuild routes progress", { "source": "controller", - "event": "heal network progress", + "event": "rebuild routes progress", "progress": {67: "pending"}, }, ) @@ -2319,7 +2327,7 @@ async def test_subscribe_heal_network_progress_initial_value( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/subscribe_heal_network_progress", + TYPE: "zwave_js/subscribe_rebuild_routes_progress", ENTRY_ID: entry.entry_id, } ) @@ -2329,13 +2337,13 @@ async def test_subscribe_heal_network_progress_initial_value( assert msg["result"] == {"67": "pending"} -async def test_stop_healing_network( +async def test_stop_rebuilding_routes( hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the stop_healing_network websocket command.""" + """Test the stop_rebuilding_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2344,7 +2352,7 @@ async def test_stop_healing_network( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2355,13 +2363,13 @@ async def test_stop_healing_network( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_stop_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_rebuilding_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2369,7 +2377,7 @@ async def test_stop_healing_network( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2378,7 +2386,7 @@ async def test_stop_healing_network( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/stop_healing_network", + TYPE: "zwave_js/stop_rebuilding_routes", ENTRY_ID: entry.entry_id, } ) @@ -2388,14 +2396,14 @@ async def test_stop_healing_network( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_heal_node( +async def test_rebuild_node_routes( hass: HomeAssistant, multisensor_6, integration, client, hass_ws_client: WebSocketGenerator, ) -> None: - """Test the heal_node websocket command.""" + """Test the rebuild_node_routes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) @@ -2405,7 +2413,7 @@ async def test_heal_node( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2416,13 +2424,13 @@ async def test_heal_node( # Test FailedZWaveCommand is caught with patch( - f"{CONTROLLER_PATCH_PREFIX}.async_heal_node", + f"{CONTROLLER_PATCH_PREFIX}.async_rebuild_node_routes", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( { ID: 4, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2430,7 +2438,7 @@ async def test_heal_node( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2439,7 +2447,7 @@ async def test_heal_node( await ws_client.send_json( { ID: 5, - TYPE: "zwave_js/heal_node", + TYPE: "zwave_js/rebuild_node_routes", DEVICE_ID: device.id, } ) @@ -2558,7 +2566,7 @@ async def test_refresh_node_info( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2635,7 +2643,7 @@ async def test_refresh_node_values( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2729,7 +2737,7 @@ async def test_refresh_node_cc_values( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -2954,7 +2962,7 @@ async def test_set_config_parameter( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3312,7 +3320,7 @@ async def test_subscribe_log_updates( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3465,7 +3473,7 @@ async def test_update_log_config( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3569,13 +3577,10 @@ async def test_data_collection( result = msg["result"] assert result is None - assert len(client.async_send_command.call_args_list) == 2 + assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args_list[0][0][0] assert args["command"] == "driver.enable_statistics" assert args["applicationName"] == "Home Assistant" - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "driver.enable_error_reporting" - assert entry.data[CONF_DATA_COLLECTION_OPTED_IN] client.async_send_command.reset_mock() @@ -3616,7 +3621,7 @@ async def test_data_collection( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test FailedZWaveCommand is caught with patch( @@ -3635,7 +3640,7 @@ async def test_data_collection( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3710,7 +3715,7 @@ async def test_abort_firmware_update( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3787,7 +3792,7 @@ async def test_is_node_firmware_update_in_progress( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4153,7 +4158,7 @@ async def test_get_node_firmware_update_capabilities( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4224,7 +4229,7 @@ async def test_is_any_ota_firmware_update_in_progress( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4300,7 +4305,7 @@ async def test_check_for_config_updates( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -4367,7 +4372,7 @@ async def test_install_config_update( assert not msg["success"] assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index fec9ec4cbbb..ba0bbbe087d 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -144,6 +144,7 @@ async def test_if_notification_notification_fires( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 113, "args": { "type": 6, @@ -273,6 +274,7 @@ async def test_if_entry_control_notification_fires( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 111, "args": { "eventType": 5, diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index e831e1dc7e8..f91250700dd 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -156,6 +156,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 113, "args": { "type": 6, @@ -187,6 +188,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 111, "args": { "eventType": 5, @@ -219,6 +221,7 @@ async def test_notifications( "source": "node", "event": "notification", "nodeId": 32, + "endpointIndex": 0, "ccId": 38, "args": {"eventType": 4, "eventTypeLabel": "test1", "direction": "up"}, }, @@ -320,6 +323,7 @@ async def test_power_level_notification( "source": "node", "event": "notification", "nodeId": 7, + "endpointIndex": 0, "ccId": 115, "args": { "commandClassName": "Powerlevel", @@ -363,6 +367,7 @@ async def test_unknown_notification( "source": "node", "event": "notification", "nodeId": node.node_id, + "endpointIndex": 0, "ccId": 0, "args": { "commandClassName": "No Operation", diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 9314b9155f5..4c3aa9f5499 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -38,24 +38,54 @@ UPDATE_ENTITY = "update.z_wave_thermostat_firmware" LATEST_VERSION_FIRMWARE = { "version": "11.2.4", "changelog": "blah 2", + "channel": "stable", "files": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], + "downgrade": True, + "normalizedVersion": "11.2.4", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, } FIRMWARE_UPDATES = { "updates": [ { "version": "10.11.1", "changelog": "blah 1", + "channel": "stable", "files": [ {"target": 0, "url": "https://example1.com", "integrity": "sha1"} ], + "downgrade": True, + "normalizedVersion": "10.11.1", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, }, LATEST_VERSION_FIRMWARE, { "version": "11.1.5", "changelog": "blah 3", + "channel": "stable", "files": [ {"target": 0, "url": "https://example3.com", "integrity": "sha3"} ], + "downgrade": True, + "normalizedVersion": "11.1.5", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, }, ] } @@ -745,7 +775,23 @@ async def test_update_entity_full_restore_data_update_available( assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.firmware_update_ota", "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, - "updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], + "updateInfo": { + "version": "11.2.4", + "changelog": "blah 2", + "channel": "stable", + "files": [ + {"target": 0, "url": "https://example2.com", "integrity": "sha2"} + ], + "downgrade": True, + "normalizedVersion": "11.2.4", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, + }, } install_task.cancel() From f899e5159b14a650871bead9b97f79d0c86df5e5 Mon Sep 17 00:00:00 2001 From: Michael Hammer Date: Tue, 26 Sep 2023 23:17:37 +0200 Subject: [PATCH 851/984] KNX: Provide project data and parser version via websocket (#100676) * feat(knxproject-explore): providing knxproject via websocket, also xknxproject version in info mesage * feat(knxproject-explore): adding test case * reverted back adding of xknxproject version * fix(): Enriching get project test case to check against FIXTURE * feat(knxproject-explore): providing knxproject via websocket, also xknxproject version in info mesage * feat(knxproject-explore): adding test case * reverted back adding of xknxproject version * fix(): Enriching get project test case to check against FIXTURE --- homeassistant/components/knx/project.py | 4 ++++ homeassistant/components/knx/websocket.py | 26 +++++++++++++++++++++++ tests/components/knx/test_websocket.py | 18 ++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py index 274ef5cb9a3..d47241b174b 100644 --- a/homeassistant/components/knx/project.py +++ b/homeassistant/components/knx/project.py @@ -115,3 +115,7 @@ class KNXProject: """Remove project file from storage.""" await self._store.async_remove() self.initial_state() + + async def get_knxproject(self) -> KNXProjectModel | None: + """Load the project file from local storage.""" + return await self._store.async_load() diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index ad29fd19928..e3eb5de8530 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -27,6 +27,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_project_file_remove) websocket_api.async_register_command(hass, ws_group_monitor_info) websocket_api.async_register_command(hass, ws_subscribe_telegram) + websocket_api.async_register_command(hass, ws_get_knx_project) if DOMAIN not in hass.data.get("frontend_panels", {}): hass.http.register_static_path( @@ -67,6 +68,7 @@ def ws_info( "name": project_info["name"], "last_modified": project_info["last_modified"], "tool_version": project_info["tool_version"], + "xknxproject_version": project_info["xknxproject_version"], } connection.send_result( @@ -80,6 +82,30 @@ def ws_info( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_knx_project", + } +) +@websocket_api.async_response +async def ws_get_knx_project( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get KNX project.""" + knx: KNXModule = hass.data[DOMAIN] + knxproject = await knx.project.get_knxproject() + connection.send_result( + msg["id"], + { + "project_loaded": knx.project.loaded, + "knxproject": knxproject, + }, + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 76a9544552f..5e5d46af4a6 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -138,6 +138,24 @@ async def test_knx_project_file_remove( assert not hass.data[DOMAIN].project.loaded +async def test_knx_get_project( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test retrieval of kxnproject from store.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert hass.data[DOMAIN].project.loaded + + await client.send_json({"id": 3, "type": "knx/get_knx_project"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["project_loaded"] is True + assert res["result"]["knxproject"] == FIXTURE_PROJECT_DATA + + async def test_knx_group_monitor_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ): From 4c21aa18db78889071eaffd3b6c388a99712b7d6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 27 Sep 2023 10:27:26 +1300 Subject: [PATCH 852/984] Add audio_settings for pipeline from ESPHome device (#100894) * Add audio_settings for pipeline from ESPHome device * ruff fixes * Bump aioesphomeapi 17.0.0 * Mypy * Fix tests --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/esphome/manager.py | 7 +- .../components/esphome/manifest.json | 2 +- .../components/esphome/voice_assistant.py | 79 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../esphome/test_voice_assistant.py | 38 +-------- 6 files changed, 50 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index ee0d2371a56..f9f24128e2a 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -16,6 +16,7 @@ from aioesphomeapi import ( RequiresEncryptionAPIError, UserService, UserServiceArgType, + VoiceAssistantAudioSettings, VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion @@ -319,7 +320,10 @@ class ESPHomeManager: self.voice_assistant_udp_server = None async def _handle_pipeline_start( - self, conversation_id: str, flags: int + self, + conversation_id: str, + flags: int, + audio_settings: VoiceAssistantAudioSettings, ) -> int | None: """Start a voice assistant pipeline.""" if self.voice_assistant_udp_server is not None: @@ -340,6 +344,7 @@ class ESPHomeManager: device_id=self.device_id, conversation_id=conversation_id or None, flags=flags, + audio_settings=audio_settings, ), "esphome.voice_assistant_udp_server.run_pipeline", ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 01e11071b69..d6fdd971fa6 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==16.0.6", + "aioesphomeapi==17.0.0", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index c501d756e54..58f9ce5abf4 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -7,14 +7,20 @@ import logging import socket from typing import cast -from aioesphomeapi import VoiceAssistantCommandFlag, VoiceAssistantEventType +from aioesphomeapi import ( + VoiceAssistantAudioSettings, + VoiceAssistantCommandFlag, + VoiceAssistantEventType, +) from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( + AudioSettings, PipelineEvent, PipelineEventType, PipelineNotFound, PipelineStage, + WakeWordSettings, async_pipeline_from_audio_stream, select as pipeline_select, ) @@ -64,7 +70,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): entry_data: RuntimeEntryData, handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], handle_finished: Callable[[], None], - audio_timeout: float = 2.0, ) -> None: """Initialize UDP receiver.""" self.context = Context() @@ -78,7 +83,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.handle_event = handle_event self.handle_finished = handle_finished self._tts_done = asyncio.Event() - self.audio_timeout = audio_timeout async def start_server(self) -> int: """Start accepting connections.""" @@ -212,9 +216,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): device_id: str, conversation_id: str | None, flags: int = 0, - pipeline_timeout: float = 30.0, + audio_settings: VoiceAssistantAudioSettings | None = None, ) -> None: """Run the Voice Assistant pipeline.""" + if audio_settings is None: + audio_settings = VoiceAssistantAudioSettings() tts_audio_output = ( "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" @@ -226,31 +232,36 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): else: start_stage = PipelineStage.STT try: - async with asyncio.timeout(pipeline_timeout): - await async_pipeline_from_audio_stream( - self.hass, - context=self.context, - event_callback=self._event_callback, - stt_metadata=stt.SpeechMetadata( - language="", # set in async_pipeline_from_audio_stream - 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=self._iterate_packets(), - pipeline_id=pipeline_select.get_chosen_pipeline( - self.hass, DOMAIN, self.device_info.mac_address - ), - conversation_id=conversation_id, - device_id=device_id, - tts_audio_output=tts_audio_output, - start_stage=start_stage, - ) + await async_pipeline_from_audio_stream( + self.hass, + context=self.context, + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + 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=self._iterate_packets(), + pipeline_id=pipeline_select.get_chosen_pipeline( + self.hass, DOMAIN, self.device_info.mac_address + ), + conversation_id=conversation_id, + device_id=device_id, + tts_audio_output=tts_audio_output, + start_stage=start_stage, + wake_word_settings=WakeWordSettings(timeout=5), + audio_settings=AudioSettings( + noise_suppression_level=audio_settings.noise_suppression_level, + auto_gain_dbfs=audio_settings.auto_gain, + volume_multiplier=audio_settings.volume_multiplier, + ), + ) - # Block until TTS is done sending - await self._tts_done.wait() + # Block until TTS is done sending + await self._tts_done.wait() _LOGGER.debug("Pipeline finished") except PipelineNotFound: @@ -271,18 +282,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): }, ) _LOGGER.warning("No Wake word provider found") - except asyncio.TimeoutError: - if self.stopped: - # The pipeline was stopped gracefully - return - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": "pipeline-timeout", - "message": "Pipeline timeout", - }, - ) - _LOGGER.warning("Pipeline timeout") finally: self.handle_finished() diff --git a/requirements_all.txt b/requirements_all.txt index 9ecfb48fba0..db2a0f8cb05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -231,7 +231,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.6 +aioesphomeapi==17.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a6181d4d4d..41304510b2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.6 +aioesphomeapi==17.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index b7ce5670441..6c54c5f62f3 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -10,7 +10,6 @@ import pytest from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, - PipelineNotFound, PipelineStage, ) from homeassistant.components.assist_pipeline.error import WakeWordDetectionError @@ -370,6 +369,8 @@ async def test_wake_word( with patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, + ), patch( + "asyncio.Event.wait" # TTS wait event ): voice_assistant_udp_server_v2.transport = Mock() @@ -377,7 +378,6 @@ async def test_wake_word( device_id="mock-device-id", conversation_id=None, flags=2, - pipeline_timeout=1, ) @@ -410,38 +410,4 @@ async def test_wake_word_exception( device_id="mock-device-id", conversation_id=None, flags=2, - pipeline_timeout=1, - ) - - -async def test_pipeline_timeout( - hass: HomeAssistant, - voice_assistant_udp_server_v2: VoiceAssistantUDPServer, -) -> None: - """Test that the pipeline is set to start with Wake word.""" - - async def async_pipeline_from_audio_stream(*args, **kwargs): - raise PipelineNotFound("not-found", "Pipeline not found") - - with patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ): - voice_assistant_udp_server_v2.transport = Mock() - - def handle_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - assert data is not None - assert data["code"] == "pipeline not found" - assert data["message"] == "Selected pipeline not found" - - voice_assistant_udp_server_v2.handle_event = handle_event - - await voice_assistant_udp_server_v2.run_pipeline( - device_id="mock-device-id", - conversation_id=None, - flags=2, - pipeline_timeout=1, ) From eab428f0e281e1b7829185a000d4926ca7d4ce2b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 26 Sep 2023 23:28:25 +0200 Subject: [PATCH 853/984] Move poolsense coordinator to its own file (#100964) --- .coveragerc | 1 + .../components/poolsense/__init__.py | 38 +--------------- .../components/poolsense/coordinator.py | 43 +++++++++++++++++++ 3 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/poolsense/coordinator.py diff --git a/.coveragerc b/.coveragerc index 6d625e73939..fe53d976bf6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -978,6 +978,7 @@ omit = homeassistant/components/point/sensor.py homeassistant/components/poolsense/__init__.py homeassistant/components/poolsense/binary_sensor.py + homeassistant/components/poolsense/coordinator.py homeassistant/components/poolsense/sensor.py homeassistant/components/powerwall/__init__.py homeassistant/components/progettihwsw/__init__.py diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 56b7eaaac77..1eccc8b455b 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -1,23 +1,17 @@ """The PoolSense integration.""" -import asyncio -from datetime import timedelta import logging from poolsense import PoolSense -from poolsense.exceptions import PoolSenseError 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 homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN +from .coordinator import PoolSenseDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -70,31 +64,3 @@ class PoolSenseEntity(CoordinatorEntity): self.entity_description = description self._attr_name = f"PoolSense {description.name}" self._attr_unique_id = f"{email}-{description.key}" - - -class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to hold PoolSense data.""" - - def __init__(self, hass, entry): - """Initialize.""" - self.poolsense = PoolSense( - aiohttp_client.async_get_clientsession(hass), - entry.data[CONF_EMAIL], - entry.data[CONF_PASSWORD], - ) - self.hass = hass - self.entry = entry - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) - - async def _async_update_data(self): - """Update data via library.""" - data = {} - async with asyncio.timeout(10): - try: - data = await self.poolsense.get_poolsense_data() - except PoolSenseError as error: - _LOGGER.error("PoolSense query did not complete") - raise UpdateFailed(error) from error - - return data diff --git a/homeassistant/components/poolsense/coordinator.py b/homeassistant/components/poolsense/coordinator.py new file mode 100644 index 00000000000..3a5089b5022 --- /dev/null +++ b/homeassistant/components/poolsense/coordinator.py @@ -0,0 +1,43 @@ +"""DataUpdateCoordinator for poolsense integration.""" +import asyncio +from datetime import timedelta +import logging + +from poolsense import PoolSense +from poolsense.exceptions import PoolSenseError + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold PoolSense data.""" + + def __init__(self, hass, entry): + """Initialize.""" + self.poolsense = PoolSense( + aiohttp_client.async_get_clientsession(hass), + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + ) + self.hass = hass + self.entry = entry + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) + + async def _async_update_data(self): + """Update data via library.""" + data = {} + async with asyncio.timeout(10): + try: + data = await self.poolsense.get_poolsense_data() + except PoolSenseError as error: + _LOGGER.error("PoolSense query did not complete") + raise UpdateFailed(error) from error + + return data From ad17f1622c17528a12e99e7075500327ed85fd2d Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Tue, 26 Sep 2023 23:32:37 +0200 Subject: [PATCH 854/984] Bump async-upnp-client to 0.36.1 (#100961) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 1e604e984b9..e269d75e0f6 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.36.1", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 2f474725ac2..0d07eb0c042 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.36.0"], + "requirements": ["async-upnp-client==0.36.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 3d5b766e55b..a3f35b65555 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.36.0" + "async-upnp-client==0.36.1" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 3ffe4c8c43a..21f0036aabd 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.36.0"] + "requirements": ["async-upnp-client==0.36.1"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 7d2ab413504..1651dea6612 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.36.1", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 3b75e4dd5fa..ecb8c1f35d2 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.36.0"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.36.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d50af885442..b9dfd0b436a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodiscover==1.5.1 aiohttp==3.8.5 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.36.0 +async-upnp-client==0.36.1 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index db2a0f8cb05..9ee86b96763 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -458,7 +458,7 @@ async-interrupt==1.1.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.0 +async-upnp-client==0.36.1 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41304510b2b..af1f908c682 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -412,7 +412,7 @@ async-interrupt==1.1.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.0 +async-upnp-client==0.36.1 # homeassistant.components.sleepiq asyncsleepiq==1.3.7 From b617451a258e88377f7fa664cef7f8acec0aa4f8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 27 Sep 2023 00:09:42 +0200 Subject: [PATCH 855/984] Add button platform to Vodafone Station (#100941) * button platform initial commit * fix is_suitable * cleanup * coveragerc * add translations * remove redundant key --- .coveragerc | 1 + .../components/vodafone_station/__init__.py | 2 +- .../components/vodafone_station/button.py | 113 ++++++++++++++++++ .../vodafone_station/coordinator.py | 20 ++++ .../components/vodafone_station/sensor.py | 17 +-- .../components/vodafone_station/strings.json | 5 + 6 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/vodafone_station/button.py diff --git a/.coveragerc b/.coveragerc index fe53d976bf6..f6f76f5db51 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1480,6 +1480,7 @@ omit = homeassistant/components/vlc_telnet/__init__.py homeassistant/components/vlc_telnet/media_player.py homeassistant/components/vodafone_station/__init__.py + homeassistant/components/vodafone_station/button.py homeassistant/components/vodafone_station/const.py homeassistant/components/vodafone_station/coordinator.py homeassistant/components/vodafone_station/device_tracker.py diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index cf2a22d2dbc..816e9241739 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import VodafoneStationRouter -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.BUTTON] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py new file mode 100644 index 00000000000..7f93f8023ef --- /dev/null +++ b/homeassistant/components/vodafone_station/button.py @@ -0,0 +1,113 @@ +"""Vodafone Station buttons.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import _LOGGER, DOMAIN +from .coordinator import VodafoneStationRouter + + +@dataclass +class VodafoneStationBaseEntityDescriptionMixin: + """Mixin to describe a Button entity.""" + + press_action: Callable[[VodafoneStationRouter], Any] + is_suitable: Callable[[dict], bool] + + +@dataclass +class VodafoneStationEntityDescription( + ButtonEntityDescription, VodafoneStationBaseEntityDescriptionMixin +): + """Vodafone Station entity description.""" + + +BUTTON_TYPES: Final = ( + VodafoneStationEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=lambda coordinator: coordinator.api.restart_router(), + is_suitable=lambda _: True, + ), + VodafoneStationEntityDescription( + key="dsl_ready", + translation_key="dsl_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection("dsl"), + is_suitable=lambda info: info.get("dsl_ready") == "1", + ), + VodafoneStationEntityDescription( + key="fiber_ready", + translation_key="fiber_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection("fiber"), + is_suitable=lambda info: info.get("fiber_ready") == "1", + ), + VodafoneStationEntityDescription( + key="vf_internet_key_online_since", + translation_key="internet_key_reconnect", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda coordinator: coordinator.api.restart_connection( + "internet_key" + ), + is_suitable=lambda info: info.get("vf_internet_key_online_since") != "", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entry.""" + _LOGGER.debug("Setting up Vodafone Station buttons") + + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + + sensors_data = coordinator.data.sensors + + async_add_entities( + VodafoneStationSensorEntity(coordinator, sensor_descr) + for sensor_descr in BUTTON_TYPES + if sensor_descr.is_suitable(sensors_data) + ) + + +class VodafoneStationSensorEntity( + CoordinatorEntity[VodafoneStationRouter], ButtonEntity +): + """Representation of a Vodafone Station button.""" + + _attr_has_entity_name = True + entity_description: VodafoneStationEntityDescription + + def __init__( + self, + coordinator: VodafoneStationRouter, + description: VodafoneStationEntityDescription, + ) -> None: + """Initialize a Vodafone Station sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" + await self.entity_description.press_action(self.coordinator) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 58079180bf8..fe1ff1889d5 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -8,6 +8,7 @@ from aiovodafone import VodafoneStationApi, VodafoneStationDevice, exceptions from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -122,3 +123,22 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): def signal_device_new(self) -> str: """Event specific per Vodafone Station entry to signal new device.""" return f"{DOMAIN}-device-new-{self._id}" + + @property + def serial_number(self) -> str: + """Device serial number.""" + return self.data.sensors["sys_serial_number"] + + @property + def device_info(self) -> DeviceInfo: + """Set device info.""" + sensors_data = self.data.sensors + return DeviceInfo( + configuration_url=self.api.base_url, + identifiers={(DOMAIN, self.serial_number)}, + name=f"Vodafone Station ({self.serial_number})", + manufacturer="Vodafone", + model=sensors_data.get("sys_model_name"), + hw_version=sensors_data["sys_hardware_version"], + sw_version=sensors_data["sys_firmware_version"], + ) diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 0ca705ad56b..ce2d3154de3 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate 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 homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -193,21 +192,9 @@ class VodafoneStationSensorEntity( ) -> None: """Initialize a Vodafone Station sensor.""" super().__init__(coordinator) - - sensors_data = coordinator.data.sensors - serial_num = sensors_data["sys_serial_number"] self.entity_description = description - - self._attr_device_info = DeviceInfo( - configuration_url=coordinator.api.base_url, - identifiers={(DOMAIN, serial_num)}, - name=f"Vodafone Station ({serial_num})", - manufacturer="Vodafone", - model=sensors_data.get("sys_model_name"), - hw_version=sensors_data["sys_hardware_version"], - sw_version=sensors_data["sys_firmware_version"], - ) - self._attr_unique_id = f"{serial_num}_{description.key}" + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index d0dcb46fd10..aaaa27a3614 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -34,6 +34,11 @@ } }, "entity": { + "button": { + "dsl_reconnect": { "name": "DSL reconnect" }, + "fiber_reconnect": { "name": "Fiber reconnect" }, + "internet_key_reconnect": { "name": "Internet key reconnect" } + }, "sensor": { "external_ipv4": { "name": "WAN IPv4 address" }, "external_ipv6": { "name": "WAN IPv6 address" }, From 8a9b2f4515fc95e5e424a279af09001346c87044 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 26 Sep 2023 17:14:48 -0500 Subject: [PATCH 856/984] Bump to webrtc-noise-gain 1.2.1 for 32-bit builds (#100942) * Bump to 1.2.0 for 32-bit builds * Bugfix with 1.2.1 --- homeassistant/components/assist_pipeline/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/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 1034d1b5f62..138f880526d 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["webrtc-noise-gain==1.1.0"] + "requirements": ["webrtc-noise-gain==1.2.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b9dfd0b436a..c5d0dd5fd33 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 -webrtc-noise-gain==1.1.0 +webrtc-noise-gain==1.2.1 yarl==1.9.2 zeroconf==0.115.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9ee86b96763..470ac28ebd8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2694,7 +2694,7 @@ waterfurnace==1.1.0 webexteamssdk==1.1.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.1.0 +webrtc-noise-gain==1.2.1 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af1f908c682..06811ebf94d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ wallbox==0.4.12 watchdog==2.3.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.1.0 +webrtc-noise-gain==1.2.1 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 From 65f307fe9c7f3cc2ffab063cef2f162b71da9856 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 26 Sep 2023 18:48:59 -0400 Subject: [PATCH 857/984] Add endpoint to `zwave_js_notification` event (#100951) --- homeassistant/components/zwave_js/__init__.py | 1 + tests/components/zwave_js/test_events.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index b56298e36ba..2ff4f40a5ad 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -741,6 +741,7 @@ class NodeEvents: ATTR_DOMAIN: DOMAIN, ATTR_NODE_ID: notification.node.node_id, ATTR_HOME_ID: driver.controller.home_id, + ATTR_ENDPOINT: notification.endpoint_idx, ATTR_DEVICE_ID: device.id, ATTR_COMMAND_CLASS: notification.command_class, } diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index f91250700dd..80b179248d8 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -173,6 +173,7 @@ async def test_notifications( assert len(events) == 1 assert events[0].data["home_id"] == client.driver.controller.home_id assert events[0].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[0].data["type"] == 6 assert events[0].data["event"] == 5 assert events[0].data["label"] == "Access Control" @@ -206,6 +207,7 @@ async def test_notifications( assert len(events) == 2 assert events[1].data["home_id"] == client.driver.controller.home_id assert events[1].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[1].data["event_type"] == 5 assert events[1].data["event_type_label"] == "test1" assert events[1].data["data_type"] == 2 @@ -233,6 +235,7 @@ async def test_notifications( assert len(events) == 3 assert events[2].data["home_id"] == client.driver.controller.home_id assert events[2].data["node_id"] == 32 + assert events[0].data["endpoint"] == 0 assert events[2].data["event_type"] == 4 assert events[2].data["event_type_label"] == "test1" assert events[2].data["direction"] == "up" From 9b574fd2c9e410db9ecaa975fc78c1ff7feded41 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 27 Sep 2023 01:24:42 +0200 Subject: [PATCH 858/984] Update frontend to 20230926.0 (#100969) --- 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 6291e3a237e..aa417b6e714 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==20230911.0"] + "requirements": ["home-assistant-frontend==20230926.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c5d0dd5fd33..5f812f14dc8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ ha-av==10.1.1 hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230911.0 +home-assistant-frontend==20230926.0 home-assistant-intents==2023.9.22 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 470ac28ebd8..03c969afecd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -997,7 +997,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230911.0 +home-assistant-frontend==20230926.0 # homeassistant.components.conversation home-assistant-intents==2023.9.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06811ebf94d..ba09e8975b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -786,7 +786,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230911.0 +home-assistant-frontend==20230926.0 # homeassistant.components.conversation home-assistant-intents==2023.9.22 From af8367a8c6afa7a7f7660c0357a082392113f22b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 26 Sep 2023 19:24:02 -0500 Subject: [PATCH 859/984] Send Wyoming Detect message during wake word detection (#100968) * Send Detect message with desired wake word * Add tests * Fix test --- .../components/wyoming/manifest.json | 2 +- homeassistant/components/wyoming/wake_word.py | 19 ++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wyoming/__init__.py | 6 ++- tests/components/wyoming/test_wake_word.py | 51 +++++++++++++++++++ 6 files changed, 76 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 810092094d1..ddb5407e1ce 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.1.0"] + "requirements": ["wyoming==1.2.0"] } diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 45d33b2a28c..c9010425c52 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -5,7 +5,7 @@ import logging from wyoming.audio import AudioChunk, AudioStart from wyoming.client import AsyncTcpClient -from wyoming.wake import Detection +from wyoming.wake import Detect, Detection from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry @@ -71,6 +71,11 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): try: async with AsyncTcpClient(self.service.host, self.service.port) as client: + # Inform client which wake word we want to detect (None = default) + await client.write_event( + Detect(names=[wake_word_id] if wake_word_id else None).event() + ) + await client.write_event( AudioStart( rate=16000, @@ -97,10 +102,20 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): break if Detection.is_type(event.type): - # Successful detection + # Possible detection detection = Detection.from_event(event) _LOGGER.info(detection) + if wake_word_id and (detection.name != wake_word_id): + _LOGGER.warning( + "Expected wake word %s but got %s, skipping", + wake_word_id, + detection.name, + ) + wake_task = asyncio.create_task(client.read_event()) + pending.add(wake_task) + continue + # Retrieve queued audio queued_audio: list[tuple[bytes, int]] | None = None if audio_task in pending: diff --git a/requirements_all.txt b/requirements_all.txt index 03c969afecd..6e9ac7f86f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2718,7 +2718,7 @@ wled==0.16.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.1.0 +wyoming==1.2.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba09e8975b9..5a9e1e838f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2021,7 +2021,7 @@ wled==0.16.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.1.0 +wyoming==1.2.0 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index c326228ec8b..e04ff4eda03 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -92,7 +92,11 @@ class MockAsyncTcpClient: async def read_event(self): """Receive.""" await asyncio.sleep(0) # force context switch - return self.responses.pop(0) + + if self.responses: + return self.responses.pop(0) + + return None async def __aenter__(self): """Enter.""" diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index eec5a16ff25..b3c09d4e816 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -106,3 +106,54 @@ async def test_streaming_audio_oserror( result = await entity.async_process_audio_stream(audio_stream(), None) assert result is None + + +async def test_detect_message_with_wake_word( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test that specifying a wake word id produces a Detect message with that id.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + yield b"chunk1", 1000 + + mock_client = MockAsyncTcpClient( + [Detection(name="my-wake-word", timestamp=1000).event()] + ) + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + mock_client, + ): + result = await entity.async_process_audio_stream(audio_stream(), "my-wake-word") + + assert isinstance(result, wake_word.DetectionResult) + assert result.wake_word_id == "my-wake-word" + + +async def test_detect_message_with_wrong_wake_word( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test that specifying a wake word id filters invalid detections.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + yield b"chunk1", 1000 + + mock_client = MockAsyncTcpClient( + [Detection(name="not-my-wake-word", timestamp=1000).event()], + ) + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + mock_client, + ): + result = await entity.async_process_audio_stream(audio_stream(), "my-wake-word") + + assert result is None From 93e2d4b74c1fc48fceb66e409d26cdde9000c58e Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 27 Sep 2023 05:20:23 +0200 Subject: [PATCH 860/984] Bump zha-quirks to 0.0.104 (#100975) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 3610cd41425..ac39aeac4fc 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -24,7 +24,7 @@ "bellows==0.36.4", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.103", + "zha-quirks==0.0.104", "zigpy-deconz==0.21.1", "zigpy==0.57.1", "zigpy-xbee==0.18.2", diff --git a/requirements_all.txt b/requirements_all.txt index 6e9ac7f86f6..549bfed2909 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2784,7 +2784,7 @@ zeroconf==0.115.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.103 +zha-quirks==0.0.104 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a9e1e838f7..c71ef40b449 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2078,7 +2078,7 @@ zeroconf==0.115.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.103 +zha-quirks==0.0.104 # homeassistant.components.zha zigpy-deconz==0.21.1 From 4e4d4992bf8e71b77437c2aaf5ca7976a910ee23 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 27 Sep 2023 00:39:07 -0400 Subject: [PATCH 861/984] Bump ZHA dependencies (#100979) --- homeassistant/components/zha/manifest.json | 11 ++++++----- requirements_all.txt | 11 +++++++---- requirements_test_all.txt | 11 +++++++---- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index ac39aeac4fc..9ce3a3eb7db 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,17 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.4", + "bellows==0.36.5", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.104", "zigpy-deconz==0.21.1", - "zigpy==0.57.1", - "zigpy-xbee==0.18.2", + "zigpy==0.57.2", + "zigpy-xbee==0.18.3", "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.4", - "universal-silabs-flasher==0.0.14" + "zigpy-znp==0.11.5", + "universal-silabs-flasher==0.0.14", + "pyserial-asyncio-fast==0.11" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 549bfed2909..5c0e7446c78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -512,7 +512,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.4 +bellows==0.36.5 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -2002,6 +2002,9 @@ pyschlage==2023.9.1 # homeassistant.components.sensibo pysensibo==1.0.35 +# homeassistant.components.zha +pyserial-asyncio-fast==0.11 + # homeassistant.components.serial # homeassistant.components.zha pyserial-asyncio==0.6 @@ -2796,16 +2799,16 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.2 +zigpy-xbee==0.18.3 # homeassistant.components.zha zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.4 +zigpy-znp==0.11.5 # homeassistant.components.zha -zigpy==0.57.1 +zigpy==0.57.2 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c71ef40b449..f22ccde834e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -436,7 +436,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.4 +bellows==0.36.5 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -1509,6 +1509,9 @@ pyschlage==2023.9.1 # homeassistant.components.sensibo pysensibo==1.0.35 +# homeassistant.components.zha +pyserial-asyncio-fast==0.11 + # homeassistant.components.serial # homeassistant.components.zha pyserial-asyncio==0.6 @@ -2084,16 +2087,16 @@ zha-quirks==0.0.104 zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.2 +zigpy-xbee==0.18.3 # homeassistant.components.zha zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.4 +zigpy-znp==0.11.5 # homeassistant.components.zha -zigpy==0.57.1 +zigpy==0.57.2 # homeassistant.components.zwave_js zwave-js-server-python==0.52.0 From 009349acf06ab429c06cdd38c311210efa6919c8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 27 Sep 2023 07:04:20 +0200 Subject: [PATCH 862/984] Move poolsense base entity to its own file (#100981) --- .coveragerc | 1 + homeassistant/components/poolsense/__init__.py | 17 +---------------- .../components/poolsense/binary_sensor.py | 2 +- homeassistant/components/poolsense/entity.py | 18 ++++++++++++++++++ homeassistant/components/poolsense/sensor.py | 2 +- 5 files changed, 22 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/poolsense/entity.py diff --git a/.coveragerc b/.coveragerc index f6f76f5db51..e24a974ef85 100644 --- a/.coveragerc +++ b/.coveragerc @@ -979,6 +979,7 @@ omit = homeassistant/components/poolsense/__init__.py homeassistant/components/poolsense/binary_sensor.py homeassistant/components/poolsense/coordinator.py + homeassistant/components/poolsense/entity.py homeassistant/components/poolsense/sensor.py homeassistant/components/powerwall/__init__.py homeassistant/components/progettihwsw/__init__.py diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 1eccc8b455b..644ecb8cf3d 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -7,10 +7,8 @@ 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 homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN +from .const import DOMAIN from .coordinator import PoolSenseDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -51,16 +49,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class PoolSenseEntity(CoordinatorEntity): - """Implements a common class elements representing the PoolSense component.""" - - _attr_attribution = ATTRIBUTION - - def __init__(self, coordinator, email, description: EntityDescription) -> None: - """Initialize poolsense sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_name = f"PoolSense {description.name}" - self._attr_unique_id = f"{email}-{description.key}" diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index e206521c3d9..56e417511bd 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -11,8 +11,8 @@ from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PoolSenseEntity from .const import DOMAIN +from .entity import PoolSenseEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( diff --git a/homeassistant/components/poolsense/entity.py b/homeassistant/components/poolsense/entity.py new file mode 100644 index 00000000000..2186d815135 --- /dev/null +++ b/homeassistant/components/poolsense/entity.py @@ -0,0 +1,18 @@ +"""Base entity for poolsense integration.""" +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION + + +class PoolSenseEntity(CoordinatorEntity): + """Implements a common class elements representing the PoolSense component.""" + + _attr_attribution = ATTRIBUTION + + def __init__(self, coordinator, email, description: EntityDescription) -> None: + """Initialize poolsense sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_name = f"PoolSense {description.name}" + self._attr_unique_id = f"{email}-{description.key}" diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index fe3535b378f..eee7518e6da 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -16,8 +16,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PoolSenseEntity from .const import DOMAIN +from .entity import PoolSenseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( From 067b94899f2f77a5ede4f661363bd71942bb3c9f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 27 Sep 2023 01:06:14 -0400 Subject: [PATCH 863/984] Move EVENT_LOGGING_CHANGED to constants (#100974) * Move EVENT_LOGGING_CHANGED to constants * fix test * remove logger as dependency for bluetooth and fix test --- homeassistant/components/bluetooth/manager.py | 2 +- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/logger/__init__.py | 2 +- homeassistant/components/logger/const.py | 2 -- homeassistant/components/logger/helpers.py | 2 +- homeassistant/components/stream/__init__.py | 3 +-- homeassistant/components/stream/manifest.json | 2 +- homeassistant/const.py | 1 + tests/components/bluetooth/test_manager.py | 1 + tests/components/stream/test_init.py | 2 +- 10 files changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 80fbe2d49a5..d69558fe7fd 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -18,7 +18,7 @@ from bluetooth_adapters import ( ) from homeassistant import config_entries -from homeassistant.components.logger import EVENT_LOGGING_CHANGED +from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import ( CALLBACK_TYPE, Event, diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ca8e7f3a3c2..66299f4fd83 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -3,7 +3,7 @@ "name": "Bluetooth", "codeowners": ["@bdraco"], "config_flow": true, - "dependencies": ["logger", "usb"], + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/bluetooth", "iot_class": "local_push", "loggers": [ diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index e7f3d6b78f1..32b864047a6 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -6,6 +6,7 @@ import re import voluptuous as vol +from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401 from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -14,7 +15,6 @@ from . import websocket_api from .const import ( ATTR_LEVEL, DOMAIN, - EVENT_LOGGING_CHANGED, # noqa: F401 LOGGER_DEFAULT, LOGGER_FILTERS, LOGGER_LOGS, diff --git a/homeassistant/components/logger/const.py b/homeassistant/components/logger/const.py index 06f2af4f3f5..4a7edfacead 100644 --- a/homeassistant/components/logger/const.py +++ b/homeassistant/components/logger/const.py @@ -35,8 +35,6 @@ LOGGER_FILTERS = "filters" ATTR_LEVEL = "level" -EVENT_LOGGING_CHANGED = "logging_changed" - STORAGE_KEY = "core.logger" STORAGE_LOG_KEY = "logs" STORAGE_VERSION = 1 diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 49996408a1d..87ec2cc8cd5 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -9,6 +9,7 @@ from enum import StrEnum import logging from typing import Any, cast +from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -16,7 +17,6 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration from .const import ( DOMAIN, - EVENT_LOGGING_CHANGED, LOGGER_DEFAULT, LOGGER_LOGS, LOGSEVERITY, diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 626a03b785f..837d11eca7c 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -29,8 +29,7 @@ from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol from yarl import URL -from homeassistant.components.logger import EVENT_LOGGING_CHANGED -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 45e9a96d759..37158aa5fe3 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], - "dependencies": ["http", "logger"], + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stream", "integration_type": "system", "iot_class": "local_push", diff --git a/homeassistant/const.py b/homeassistant/const.py index de968451af9..5585413e97b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -288,6 +288,7 @@ EVENT_HOMEASSISTANT_STARTED: Final = "homeassistant_started" EVENT_HOMEASSISTANT_STOP: Final = "homeassistant_stop" EVENT_HOMEASSISTANT_FINAL_WRITE: Final = "homeassistant_final_write" EVENT_LOGBOOK_ENTRY: Final = "logbook_entry" +EVENT_LOGGING_CHANGED: Final = "logging_changed" EVENT_SERVICE_REGISTERED: Final = "service_registered" EVENT_SERVICE_REMOVED: Final = "service_removed" EVENT_STATE_CHANGED: Final = "state_changed" diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 63091b18843..6c89074e471 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1011,6 +1011,7 @@ async def test_debug_logging( caplog: pytest.LogCaptureFixture, ) -> None: """Test debug logging.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) await hass.services.async_call( "logger", "set_level", diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py index 525eb9d859d..8372e5d5e61 100644 --- a/tests/components/stream/test_init.py +++ b/tests/components/stream/test_init.py @@ -4,8 +4,8 @@ import logging import av import pytest -from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.components.stream import __name__ as stream_name +from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component From 70e3da207a7df4394f9bcee25d0d35d715f483f1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 27 Sep 2023 01:17:52 -0400 Subject: [PATCH 864/984] Automatically enable/disable zwave_js server logging in lib (#100837) * Bump zwave-js-server-python to 0.52.0 * Add WS command to enabled zwave_js server logging in lib * enable and disable server logging automatically * fix conditionals * fix tests * Add logging * small tweaks * Add logger as a dependency * fix test * Prepare for movement of event constant * Add constant so tests pass --- homeassistant/components/zwave_js/__init__.py | 25 +++++ homeassistant/components/zwave_js/api.py | 7 +- homeassistant/components/zwave_js/const.py | 2 + homeassistant/components/zwave_js/helpers.py | 79 ++++++++++++++-- .../components/zwave_js/manifest.json | 2 +- tests/components/zwave_js/test_api.py | 3 + tests/components/zwave_js/test_init.py | 94 +++++++++++++++++++ 7 files changed, 200 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 2ff4f40a5ad..b9a26630406 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections import defaultdict from collections.abc import Coroutine from contextlib import suppress +import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient @@ -29,6 +30,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_URL, EVENT_HOMEASSISTANT_STOP, + EVENT_LOGGING_CHANGED, Platform, ) from homeassistant.core import Event, HomeAssistant, callback @@ -93,6 +95,7 @@ from .const import ( DATA_CLIENT, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, + LIB_LOGGER, LOGGER, USER_AGENT, ZWAVE_JS_NOTIFICATION_EVENT, @@ -105,6 +108,8 @@ from .discovery import ( async_discover_single_value, ) from .helpers import ( + async_disable_server_logging_if_needed, + async_enable_server_logging_if_needed, async_enable_statistics, get_device_id, get_device_id_ext, @@ -249,6 +254,24 @@ class DriverEvents: elif opted_in is False: await driver.async_disable_statistics() + async def handle_logging_changed(_: Event | None = None) -> None: + """Handle logging changed event.""" + if LIB_LOGGER.isEnabledFor(logging.DEBUG): + await async_enable_server_logging_if_needed( + self.hass, self.config_entry, driver + ) + else: + await async_disable_server_logging_if_needed( + self.hass, self.config_entry, driver + ) + + # Set up server logging on setup if needed + await handle_logging_changed() + + self.config_entry.async_on_unload( + self.hass.bus.async_listen(EVENT_LOGGING_CHANGED, handle_logging_changed) + ) + # Check for nodes that no longer exist and remove them stored_devices = dr.async_entries_for_config_entry( self.dev_reg, self.config_entry.entry_id @@ -902,6 +925,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = all(await asyncio.gather(*tasks)) if tasks else True + if hasattr(driver_events, "driver"): + await async_disable_server_logging_if_needed(hass, entry, driver_events.driver) if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 314b53456aa..8658dc1cc1f 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -82,7 +82,6 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, - async_update_data_collection_preference, get_device_id, ) @@ -490,6 +489,7 @@ async def websocket_network_status( "state": "connected" if client.connected else "disconnected", "driver_version": client_version_info.driver_version, "server_version": client_version_info.server_version, + "server_logging_enabled": client.server_logging_enabled, }, "controller": { "home_id": controller.home_id, @@ -1875,7 +1875,10 @@ async def websocket_update_data_collection_preference( ) -> None: """Update preference for data collection and enable/disable collection.""" opted_in = msg[OPTED_IN] - async_update_data_collection_preference(hass, entry, opted_in) + if entry.data.get(CONF_DATA_COLLECTION_OPTED_IN) != opted_in: + new_data = entry.data.copy() + new_data[CONF_DATA_COLLECTION_OPTED_IN] = opted_in + hass.config_entries.async_update_entry(entry, data=new_data) if opted_in: await async_enable_statistics(driver) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 5ee8b300603..34c6fa3363e 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -31,10 +31,12 @@ CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" DOMAIN = "zwave_js" DATA_CLIENT = "client" +DATA_OLD_SERVER_LOG_LEVEL = "old_server_log_level" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" LOGGER = logging.getLogger(__package__) +LIB_LOGGER = logging.getLogger("zwave_js_server") # constants extra state attributes ATTR_RESERVED_VALUES = "reserved_values" # ConfigurationValue number entities diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index a163b8636e8..8774bcea73f 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -8,8 +8,14 @@ from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.const import ( + LOG_LEVEL_MAP, + CommandClass, + ConfigurationValueType, + LogLevel, +) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue, @@ -39,9 +45,10 @@ from .const import ( ATTR_ENDPOINT, ATTR_PROPERTY, ATTR_PROPERTY_KEY, - CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, + DATA_OLD_SERVER_LOG_LEVEL, DOMAIN, + LIB_LOGGER, LOGGER, ) @@ -127,14 +134,68 @@ async def async_enable_statistics(driver: Driver) -> None: await driver.async_enable_statistics("Home Assistant", HA_VERSION) -@callback -def async_update_data_collection_preference( - hass: HomeAssistant, entry: ConfigEntry, preference: bool +async def async_enable_server_logging_if_needed( + hass: HomeAssistant, entry: ConfigEntry, driver: Driver ) -> None: - """Update data collection preference on config entry.""" - new_data = entry.data.copy() - new_data[CONF_DATA_COLLECTION_OPTED_IN] = preference - hass.config_entries.async_update_entry(entry, data=new_data) + """Enable logging of zwave-js-server in the lib.""" + # If lib log level is set to debug, we want to enable server logging. First we + # check if server log level is less verbose than library logging, and if so, set it + # to debug to match library logging. We will store the old server log level in + # hass.data so we can reset it later + if ( + not driver + or not driver.client.connected + or driver.client.server_logging_enabled + ): + return + + LOGGER.info("Enabling zwave-js-server logging") + if (curr_server_log_level := driver.log_config.level) and ( + LOG_LEVEL_MAP[curr_server_log_level] + ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()): + entry_data = hass.data[DOMAIN][entry.entry_id] + LOGGER.warning( + ( + "Server logging is set to %s and is currently less verbose " + "than library logging, setting server log level to %s to match" + ), + curr_server_log_level, + logging.getLevelName(lib_log_level), + ) + entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level + await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG)) + await driver.client.enable_server_logging() + LOGGER.info("Zwave-js-server logging is enabled") + + +async def async_disable_server_logging_if_needed( + hass: HomeAssistant, entry: ConfigEntry, driver: Driver +) -> None: + """Disable logging of zwave-js-server in the lib if still connected to server.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + if ( + not driver + or not driver.client.connected + or not driver.client.server_logging_enabled + ): + return + LOGGER.info("Disabling zwave_js server logging") + if ( + DATA_OLD_SERVER_LOG_LEVEL in entry_data + and (old_server_log_level := entry_data.pop(DATA_OLD_SERVER_LOG_LEVEL)) + != driver.log_config.level + ): + LOGGER.info( + ( + "Server logging is currently set to %s as a result of server logging " + "being enabled. It is now being reset to %s" + ), + driver.log_config.level, + old_server_log_level, + ) + await driver.async_update_log_config(LogConfig(level=old_server_log_level)) + await driver.client.disable_server_logging() + LOGGER.info("Zwave-js-server logging is enabled") def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 4c697a9c2b7..3e8a5e4f757 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave", "codeowners": ["@home-assistant/z-wave"], "config_flow": true, - "dependencies": ["usb", "http", "repairs", "websocket_api"], + "dependencies": ["http", "repairs", "usb", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/zwave_js", "integration_type": "hub", "iot_class": "local_push", diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 0c0b3c7e132..965b1ea4f1b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -126,12 +126,14 @@ async def test_network_status( hass: HomeAssistant, multisensor_6, controller_state, + client, integration, hass_ws_client: WebSocketGenerator, ) -> None: """Test the network status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) + client.server_logging_enabled = False # Try API call with entry ID with patch( @@ -150,6 +152,7 @@ async def test_network_status( assert result["client"]["ws_server_url"] == "ws://test:3000/zjs" assert result["client"]["server_version"] == "1.0.0" + assert not result["client"]["server_logging_enabled"] assert result["controller"]["inclusion_state"] == InclusionState.IDLE # Try API call with device ID diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 6985a7bf252..1203997839e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS init module.""" import asyncio from copy import deepcopy +import logging from unittest.mock import AsyncMock, call, patch import pytest @@ -11,6 +12,7 @@ from zwave_js_server.model.node import Node from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id @@ -23,6 +25,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) +from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY @@ -1550,3 +1553,94 @@ async def test_identify_event( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] + + +async def test_server_logging(hass: HomeAssistant, client) -> None: + """Test automatic server logging functionality.""" + + def _reset_mocks(): + client.async_send_command.reset_mock() + client.enable_server_logging.reset_mock() + client.disable_server_logging.reset_mock() + + # Set server logging to disabled + client.server_logging_enabled = False + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Setup logger and set log level to debug to trigger event listener + assert await async_setup_component(hass, "logger", {"logger": {}}) + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.INFO + client.async_send_command.reset_mock() + await hass.services.async_call( + LOGGER_DOMAIN, SERVICE_SET_LEVEL, {"zwave_js_server": "debug"}, blocking=True + ) + await hass.async_block_till_done() + assert logging.getLogger("zwave_js_server").getEffectiveLevel() == logging.DEBUG + + # Validate that the server logging was enabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "debug"}, + } + assert client.enable_server_logging.called + assert not client.disable_server_logging.called + + _reset_mocks() + + # Emulate server by setting log level to debug + event = Event( + type="log config updated", + data={ + "source": "driver", + "event": "log config updated", + "config": { + "enabled": False, + "level": "debug", + "logToFile": True, + "filename": "test", + "forceConsole": True, + }, + }, + ) + client.driver.receive_event(event) + + # "Enable" server logging and unload the entry + client.server_logging_enabled = True + await hass.config_entries.async_unload(entry.entry_id) + + # Validate that the server logging was disabled + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "driver.update_log_config", + "config": {"level": "info"}, + } + assert not client.enable_server_logging.called + assert client.disable_server_logging.called + + _reset_mocks() + + # Validate that the server logging doesn't get enabled because HA thinks it already + # is enabled + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called + + _reset_mocks() + + # "Disable" server logging and unload the entry + client.server_logging_enabled = False + await hass.config_entries.async_unload(entry.entry_id) + + # Validate that the server logging was not disabled because HA thinks it is already + # is disabled + assert len(client.async_send_command.call_args_list) == 0 + assert not client.enable_server_logging.called + assert not client.disable_server_logging.called From e5567c09b9d3dd55bb90f47511287a2ae4a374f7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Sep 2023 08:33:43 +0200 Subject: [PATCH 865/984] Deprecate Withings YAML (#100967) --- homeassistant/components/withings/__init__.py | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index e9721719eef..ec7d96ec2fa 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -34,20 +34,20 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + CoreState, + Event, + HomeAssistant, + ServiceCall, +) from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi -from .const import ( - CONF_CLOUDHOOK_URL, - CONF_PROFILES, - CONF_USE_WEBHOOK, - CONFIG, - DOMAIN, - LOGGER, -) +from .const import CONF_CLOUDHOOK_URL, CONF_PROFILES, CONF_USE_WEBHOOK, DOMAIN, LOGGER from .coordinator import WithingsDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -81,31 +81,30 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Withings component.""" - if not (conf := config.get(DOMAIN)): - # Apply the defaults. - conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] - hass.data[DOMAIN] = {CONFIG: conf} - return True - hass.data[DOMAIN] = {CONFIG: conf} - - # Setup the oauth2 config flow. - if CONF_CLIENT_ID in conf: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Withings", + }, + ) + if CONF_CLIENT_ID in config: await async_import_client_credential( hass, DOMAIN, ClientCredential( - conf[CONF_CLIENT_ID], - conf[CONF_CLIENT_SECRET], + config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], ), ) - LOGGER.warning( - "Configuration of Withings integration OAuth2 credentials in YAML " - "is deprecated and will be removed in a future release; Your " - "existing OAuth Application Credentials have been imported into " - "the UI automatically and can be safely removed from your " - "configuration.yaml file" - ) return True From db5943ad6d7570b4e91c2ebb422b7d151917c11f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 27 Sep 2023 08:52:46 +0200 Subject: [PATCH 866/984] Improve Comelit login with PIN (#100860) * improve login * library bump --- homeassistant/components/comelit/coordinator.py | 12 +++++------- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index df1d745ce8a..a9c281c10c0 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -71,16 +71,14 @@ class ComelitSerialBridge(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update router data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + logged = False try: logged = await self.api.login() except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err: _LOGGER.warning("Connection error for %s", self._host) raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + finally: + if not logged: + raise ConfigEntryAuthFailed - if not logged: - raise ConfigEntryAuthFailed - - devices_data = await self.api.get_all_devices() - await self.api.logout() - - return devices_data + return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index ee876434825..3e49996e50e 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.0.8"] + "requirements": ["aiocomelit==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5c0e7446c78..45bdb08b7fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -210,7 +210,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.8 +aiocomelit==0.0.9 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f22ccde834e..e78e1d2658d 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.0.8 +aiocomelit==0.0.9 # homeassistant.components.dhcp aiodiscover==1.5.1 From d70308ae9fd65512fff3e4b287fe520d8c802756 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 27 Sep 2023 08:56:38 +0200 Subject: [PATCH 867/984] Add cover support to Comelit (#100904) * add cover support to Comelit * coveragerc * Update homeassistant/components/comelit/cover.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/comelit/cover.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/comelit/cover.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + homeassistant/components/comelit/__init__.py | 2 +- homeassistant/components/comelit/cover.py | 101 +++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/comelit/cover.py diff --git a/.coveragerc b/.coveragerc index e24a974ef85..ed621cbff10 100644 --- a/.coveragerc +++ b/.coveragerc @@ -175,6 +175,7 @@ omit = homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comelit/__init__.py homeassistant/components/comelit/const.py + homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py homeassistant/components/comelit/light.py homeassistant/components/comfoconnect/fan.py diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 2c73922582c..1a0d49f0666 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import ComelitSerialBridge -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.LIGHT, Platform.COVER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py new file mode 100644 index 00000000000..022ce05ff6d --- /dev/null +++ b/homeassistant/components/comelit/cover.py @@ -0,0 +1,101 @@ +"""Support for covers.""" +from __future__ import annotations + +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import COVER, COVER_CLOSE, COVER_OPEN, COVER_STATUS + +from homeassistant.components.cover import CoverDeviceClass, CoverEntity +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 .const import DOMAIN +from .coordinator import ComelitSerialBridge + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit covers.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + async_add_entities( + ComelitCoverEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[COVER].values() + ) + + +class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): + """Cover device.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_unique_id: str, + ) -> None: + """Init cover entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device, COVER) + # Device doesn't provide a status so we assume CLOSE at startup + self._last_action = COVER_STATUS.index("closing") + + def _current_action(self, action: str) -> bool: + """Return the current cover action.""" + is_moving = self.device_status == COVER_STATUS.index(action) + if is_moving: + self._last_action = COVER_STATUS.index(action) + return is_moving + + @property + def device_status(self) -> int: + """Return current device status.""" + return self.coordinator.data[COVER][self._device.index].status + + @property + def is_closed(self) -> bool: + """Return True if cover is closed.""" + if self.device_status != COVER_STATUS.index("stopped"): + return False + + return bool(self._last_action == COVER_STATUS.index("closing")) + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return self._current_action("closing") + + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return self._current_action("opening") + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self._api.cover_move(self._device.index, COVER_CLOSE) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open cover.""" + await self._api.cover_move(self._device.index, COVER_OPEN) + + async def async_stop_cover(self, **_kwargs: Any) -> None: + """Stop the cover.""" + if not self.is_closing and not self.is_opening: + return + + action = COVER_OPEN if self.is_closing else COVER_CLOSE + await self._api.cover_move(self._device.index, action) From 4788dd290513a8b7dba38921846f35ac92480c28 Mon Sep 17 00:00:00 2001 From: Marty Sun Date: Wed, 27 Sep 2023 14:59:15 +0800 Subject: [PATCH 868/984] Add "start_irrigation" service for Yardian (#100257) Co-authored-by: J. Nick Koston --- homeassistant/components/yardian/services.yaml | 14 ++++++++++++++ homeassistant/components/yardian/strings.json | 12 ++++++++++++ homeassistant/components/yardian/switch.py | 15 +++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 homeassistant/components/yardian/services.yaml diff --git a/homeassistant/components/yardian/services.yaml b/homeassistant/components/yardian/services.yaml new file mode 100644 index 00000000000..a8d05133f51 --- /dev/null +++ b/homeassistant/components/yardian/services.yaml @@ -0,0 +1,14 @@ +start_irrigation: + target: + entity: + integration: yardian + domain: switch + fields: + duration: + required: true + default: 6 + selector: + number: + min: 1 + max: 1440 + unit_of_measurement: "minutes" diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index 6577c99456c..f841f3d3ed1 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -16,5 +16,17 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "start_irrigation": { + "name": "Start irrigation", + "description": "Starts the irrigation.", + "fields": { + "duration": { + "name": "Duration", + "description": "Duration for the target to be turned on." + } + } + } } } diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index af5703e0fd4..8598e4a8732 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -3,15 +3,23 @@ from __future__ import annotations from typing import Any +import voluptuous as vol + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_WATERING_DURATION, DOMAIN from .coordinator import YardianUpdateCoordinator +SERVICE_START_IRRIGATION = "start_irrigation" +SERVICE_SCHEMA_START_IRRIGATION = { + vol.Required("duration"): cv.positive_int, +} + async def async_setup_entry( hass: HomeAssistant, @@ -28,6 +36,13 @@ async def async_setup_entry( for i in range(len(coordinator.data.zones)) ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_START_IRRIGATION, + SERVICE_SCHEMA_START_IRRIGATION, + "async_turn_on", + ) + class YardianSwitch(CoordinatorEntity[YardianUpdateCoordinator], SwitchEntity): """Representation of a Yardian switch.""" From d2bcf88bbfd050410e05a5ab99d1b678508e3f24 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Sep 2023 09:39:57 +0200 Subject: [PATCH 869/984] Set device name as entity name for Comelit lights (#100986) Fix entity name for Comelit lights --- homeassistant/components/comelit/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index a4a534025f0..64fb081243a 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -36,6 +36,7 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): """Light device.""" _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -47,7 +48,6 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): self._api = coordinator.api self._device = device super().__init__(coordinator) - self._attr_name = device.name self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" self._attr_device_info = self.coordinator.platform_device_info(device, LIGHT) From 0f38cd579485045e0b7fb574c06c8fff58e0176a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 27 Sep 2023 09:50:01 +0200 Subject: [PATCH 870/984] Allow to reset an mqtt lock to an unknown state (#100985) --- homeassistant/components/mqtt/lock.py | 11 +++++++++-- tests/components/mqtt/test_lock.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index b565e8a4b57..9a0ce2077f3 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -28,6 +28,7 @@ from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_ENCODING, + CONF_PAYLOAD_RESET, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, @@ -63,6 +64,7 @@ DEFAULT_NAME = "MQTT Lock" DEFAULT_PAYLOAD_LOCK = "LOCK" DEFAULT_PAYLOAD_UNLOCK = "UNLOCK" DEFAULT_PAYLOAD_OPEN = "OPEN" +DEFAULT_PAYLOAD_RESET = "None" DEFAULT_STATE_LOCKED = "LOCKED" DEFAULT_STATE_LOCKING = "LOCKING" DEFAULT_STATE_UNLOCKED = "UNLOCKED" @@ -84,6 +86,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_LOCK, default=DEFAULT_PAYLOAD_LOCK): cv.string, vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK): cv.string, vol.Optional(CONF_PAYLOAD_OPEN): cv.string, + vol.Optional(CONF_PAYLOAD_RESET, default=DEFAULT_PAYLOAD_RESET): cv.string, vol.Optional(CONF_STATE_JAMMED, default=DEFAULT_STATE_JAMMED): cv.string, vol.Optional(CONF_STATE_LOCKED, default=DEFAULT_STATE_LOCKED): cv.string, vol.Optional(CONF_STATE_LOCKING, default=DEFAULT_STATE_LOCKING): cv.string, @@ -197,8 +200,12 @@ class MqttLock(MqttEntity, LockEntity): ) def message_received(msg: ReceiveMessage) -> None: """Handle new lock state messages.""" - payload = self._value_template(msg.payload) - if payload in self._valid_states: + if (payload := self._value_template(msg.payload)) == self._config[ + CONF_PAYLOAD_RESET + ]: + # Reset the state to `unknown` + self._attr_is_locked = None + elif payload in self._valid_states: self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED] self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING] self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 0045e003804..e128590c907 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -126,6 +126,7 @@ async def test_controlling_state_via_topic( (CONFIG_WITH_STATES, "closing", STATE_LOCKING), (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "None", STATE_UNKNOWN), ], ) async def test_controlling_non_default_state_via_topic( @@ -186,6 +187,15 @@ async def test_controlling_non_default_state_via_topic( '{"val":"open"}', STATE_UNLOCKED, ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":null}', + STATE_UNKNOWN, + ), ], ) async def test_controlling_state_via_topic_and_json_message( From fd53e116bb2f1a62a00615cb5bb509c6401992d1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Sep 2023 10:11:42 +0200 Subject: [PATCH 871/984] Add config flow to color extractor (#100862) --- .../components/color_extractor/__init__.py | 17 ++++- .../components/color_extractor/config_flow.py | 64 +++++++++++++++++ .../components/color_extractor/const.py | 1 + .../components/color_extractor/manifest.json | 2 +- .../components/color_extractor/strings.json | 16 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- .../color_extractor/test_config_flow.py | 70 +++++++++++++++++++ .../color_extractor/test_service.py | 2 +- 9 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/color_extractor/config_flow.py create mode 100644 tests/components/color_extractor/test_config_flow.py diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index fb04ebb76a4..af460f819cd 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -13,6 +13,7 @@ from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, LIGHT_TURN_ON_SCHEMA, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import aiohttp_client @@ -58,8 +59,20 @@ def _get_color(file_handler) -> tuple: return color -async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: - """Set up services for color_extractor integration.""" +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Color extractor component.""" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={} + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load a config entry.""" async def async_handle_service(service_call: ServiceCall) -> None: """Decide which color_extractor method to call based on service.""" diff --git a/homeassistant/components/color_extractor/config_flow.py b/homeassistant/components/color_extractor/config_flow.py new file mode 100644 index 00000000000..32b803d14f9 --- /dev/null +++ b/homeassistant/components/color_extractor/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow to configure the Color extractor integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DEFAULT_NAME, DOMAIN + + +class ColorExtractorConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Color extractor.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry(title=DEFAULT_NAME, data={}) + + return self.async_show_form(step_id="user") + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import from configuration.yaml.""" + result = await self.async_step_user(user_input) + if result["type"] == FlowResultType.CREATE_ENTRY: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Color extractor", + }, + ) + else: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Color extractor", + }, + ) + return result diff --git a/homeassistant/components/color_extractor/const.py b/homeassistant/components/color_extractor/const.py index a6c59ea434b..e783dcb533d 100644 --- a/homeassistant/components/color_extractor/const.py +++ b/homeassistant/components/color_extractor/const.py @@ -3,5 +3,6 @@ ATTR_PATH = "color_extract_path" ATTR_URL = "color_extract_url" DOMAIN = "color_extractor" +DEFAULT_NAME = "Color extractor" SERVICE_TURN_ON = "turn_on" diff --git a/homeassistant/components/color_extractor/manifest.json b/homeassistant/components/color_extractor/manifest.json index 07e9b43a5e5..c87ac2540a6 100644 --- a/homeassistant/components/color_extractor/manifest.json +++ b/homeassistant/components/color_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "color_extractor", "name": "ColorExtractor", "codeowners": ["@GenericStudent"], - "config_flow": false, + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/color_extractor", "requirements": ["colorthief==0.2.1"] } diff --git a/homeassistant/components/color_extractor/strings.json b/homeassistant/components/color_extractor/strings.json index 3dc02f56030..aa5fd5f4ef7 100644 --- a/homeassistant/components/color_extractor/strings.json +++ b/homeassistant/components/color_extractor/strings.json @@ -1,4 +1,20 @@ { + "config": { + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour configuration is already imported.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + }, "services": { "turn_on": { "name": "[%key:common::action::turn_on%]", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index acf324c107f..c3ee346664a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -83,6 +83,7 @@ FLOWS = { "cloudflare", "co2signal", "coinbase", + "color_extractor", "comelit", "control4", "coolmaster", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 394bfa4f391..bc759ec1ae6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -876,7 +876,7 @@ "color_extractor": { "name": "ColorExtractor", "integration_type": "hub", - "config_flow": false + "config_flow": true }, "comed": { "name": "Commonwealth Edison (ComEd)", diff --git a/tests/components/color_extractor/test_config_flow.py b/tests/components/color_extractor/test_config_flow.py new file mode 100644 index 00000000000..9dc928da73f --- /dev/null +++ b/tests/components/color_extractor/test_config_flow.py @@ -0,0 +1,70 @@ +"""Tests for the Color extractor config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.color_extractor.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + with patch( + "homeassistant.components.color_extractor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "Color extractor" + assert result.get("data") == {} + assert result.get("options") == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +async def test_single_instance_allowed( + hass: HomeAssistant, + source: str, +) -> None: + """Test we abort if already setup.""" + mock_config_entry = MockConfigEntry(domain=DOMAIN) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data={} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" + + +async def test_import_flow( + hass: HomeAssistant, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={}, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "Color extractor" + assert result.get("data") == {} + assert result.get("options") == {} diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 31218387858..ae3e799e9d2 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -63,7 +63,7 @@ def _close_enough(actual_rgb, testing_rgb): @pytest.fixture(autouse=True) -async def setup_light(hass): +async def setup_light(hass: HomeAssistant): """Configure our light component to work against for testing.""" assert await async_setup_component( hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": "demo"}} From 43954d660b16c12c2f405f1baf352ef1ec9d743f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 27 Sep 2023 10:11:57 +0200 Subject: [PATCH 872/984] Add trigger weather template (#100824) * Add trigger weather template * Add tests * mod dataclass * Remove legacy forecast * Fix test failure * sorting * add hourly test * Add tests * Add and fix tests * Improve tests --------- Co-authored-by: Erik --- homeassistant/components/template/config.py | 5 + homeassistant/components/template/weather.py | 329 +++++++++++-- tests/components/template/test_weather.py | 470 ++++++++++++++++++- 3 files changed, 768 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 54c82d88c74..3329f185f08 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -9,6 +9,7 @@ from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_exception, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.helpers import config_validation as cv @@ -21,6 +22,7 @@ from . import ( number as number_platform, select as select_platform, sensor as sensor_platform, + weather as weather_platform, ) from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN @@ -55,6 +57,9 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(IMAGE_DOMAIN): vol.All( cv.ensure_list, [image_platform.IMAGE_SCHEMA] ), + vol.Optional(WEATHER_DOMAIN): vol.All( + cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + ), } ) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index d815655d775..4e9149ebd07 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,8 +1,9 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations +from dataclasses import asdict, dataclass from functools import partial -from typing import Any, Literal +from typing import Any, Literal, Self import voluptuous as vol @@ -22,18 +23,27 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, + DOMAIN as WEATHER_DOMAIN, ENTITY_ID_FORMAT, Forecast, WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -42,7 +52,9 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, ) +from .coordinator import TriggerUpdateCoordinator from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( set().union(Forecast.__annotations__.keys()) @@ -92,40 +104,38 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" +WEATHER_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + } +) + PLATFORM_SCHEMA = vol.All( cv.deprecated(CONF_FORECAST_TEMPLATE), - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, - vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( - TemperatureConverter.VALID_UNITS - ), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_PRECIPITATION_UNIT): vol.In( - DistanceConverter.VALID_UNITS - ), - vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, - } - ), + PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema), ) @@ -136,6 +146,12 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" + if discovery_info and "coordinator" in discovery_info: + async_add_entities( + TriggerWeatherEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return config = rewrite_common_legacy_to_modern_conf(config) unique_id = config.get(CONF_UNIQUE_ID) @@ -452,3 +468,248 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): ) continue return result + + +@dataclass(kw_only=True) +class WeatherExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + last_apparent_temperature: float | None + last_cloud_coverage: int | None + last_dew_point: float | None + last_humidity: float | None + last_ozone: float | None + last_pressure: float | None + last_temperature: float | None + last_visibility: float | None + last_wind_bearing: float | str | None + last_wind_gust_speed: float | None + last_wind_speed: float | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored event state from a dict.""" + try: + return cls( + last_apparent_temperature=restored["last_apparent_temperature"], + last_cloud_coverage=restored["last_cloud_coverage"], + last_dew_point=restored["last_dew_point"], + last_humidity=restored["last_humidity"], + last_ozone=restored["last_ozone"], + last_pressure=restored["last_pressure"], + last_temperature=restored["last_temperature"], + last_visibility=restored["last_visibility"], + last_wind_bearing=restored["last_wind_bearing"], + last_wind_gust_speed=restored["last_wind_gust_speed"], + last_wind_speed=restored["last_wind_speed"], + ) + except KeyError: + return None + + +class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): + """Sensor entity based on trigger data.""" + + domain = WEATHER_DOMAIN + extra_template_keys = ( + CONF_CONDITION_TEMPLATE, + CONF_TEMPERATURE_TEMPLATE, + CONF_HUMIDITY_TEMPLATE, + ) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize.""" + super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) + self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) + self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) + self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) + self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) + + self._attr_supported_features = 0 + if config.get(CONF_FORECAST_DAILY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if config.get(CONF_FORECAST_HOURLY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if config.get(CONF_FORECAST_TWICE_DAILY_TEMPLATE): + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + + for key in ( + CONF_APPARENT_TEMPERATURE_TEMPLATE, + CONF_CLOUD_COVERAGE_TEMPLATE, + CONF_DEW_POINT_TEMPLATE, + CONF_FORECAST_DAILY_TEMPLATE, + CONF_FORECAST_HOURLY_TEMPLATE, + CONF_FORECAST_TWICE_DAILY_TEMPLATE, + CONF_OZONE_TEMPLATE, + CONF_PRESSURE_TEMPLATE, + CONF_VISIBILITY_TEMPLATE, + CONF_WIND_BEARING_TEMPLATE, + CONF_WIND_GUST_SPEED_TEMPLATE, + CONF_WIND_SPEED_TEMPLATE, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + (state := await self.async_get_last_state()) + and state.state is not None + and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and (weather_data := await self.async_get_last_weather_data()) + ): + self._rendered[ + CONF_APPARENT_TEMPERATURE_TEMPLATE + ] = weather_data.last_apparent_temperature + self._rendered[ + CONF_CLOUD_COVERAGE_TEMPLATE + ] = weather_data.last_cloud_coverage + self._rendered[CONF_CONDITION_TEMPLATE] = state.state + self._rendered[CONF_DEW_POINT_TEMPLATE] = weather_data.last_dew_point + self._rendered[CONF_HUMIDITY_TEMPLATE] = weather_data.last_humidity + self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone + self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure + self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility + self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing + self._rendered[ + CONF_WIND_GUST_SPEED_TEMPLATE + ] = weather_data.last_wind_gust_speed + self._rendered[CONF_WIND_SPEED_TEMPLATE] = weather_data.last_wind_speed + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._rendered.get(CONF_CONDITION_TEMPLATE) + + @property + def native_temperature(self) -> float | None: + """Return the temperature.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_TEMPERATURE_TEMPLATE) + ) + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_HUMIDITY_TEMPLATE) + ) + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_WIND_SPEED_TEMPLATE) + ) + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return vol.Any(vol.Coerce(float), vol.Coerce(str), None)( + self._rendered.get(CONF_WIND_BEARING_TEMPLATE) + ) + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_OZONE_TEMPLATE), + ) + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_VISIBILITY_TEMPLATE) + ) + + @property + def native_pressure(self) -> float | None: + """Return the air pressure.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_PRESSURE_TEMPLATE) + ) + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE) + ) + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_CLOUD_COVERAGE_TEMPLATE) + ) + + @property + def native_dew_point(self) -> float | None: + """Return the dew point.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_DEW_POINT_TEMPLATE) + ) + + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_APPARENT_TEMPERATURE_TEMPLATE) + ) + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_DAILY_TEMPLATE) + ) + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_HOURLY_TEMPLATE) + ) + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return vol.Any(vol.Coerce(list), None)( + self._rendered.get(CONF_FORECAST_TWICE_DAILY_TEMPLATE) + ) + + @property + def extra_restore_state_data(self) -> WeatherExtraStoredData: + """Return weather specific state data to be restored.""" + return WeatherExtraStoredData( + last_apparent_temperature=self._rendered.get( + CONF_APPARENT_TEMPERATURE_TEMPLATE + ), + last_cloud_coverage=self._rendered.get(CONF_CLOUD_COVERAGE_TEMPLATE), + last_dew_point=self._rendered.get(CONF_DEW_POINT_TEMPLATE), + last_humidity=self._rendered.get(CONF_HUMIDITY_TEMPLATE), + last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), + last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), + last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), + last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), + last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), + last_wind_speed=self._rendered.get(CONF_WIND_SPEED_TEMPLATE), + ) + + async def async_get_last_weather_data(self) -> WeatherExtraStoredData | None: + """Restore weather specific state data.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return WeatherExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 97965a5643e..7ca3d11b099 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -1,4 +1,6 @@ """The tests for the Template Weather platform.""" +from typing import Any + import pytest from homeassistant.components.weather import ( @@ -18,8 +20,18 @@ from homeassistant.components.weather import ( SERVICE_GET_FORECAST, Forecast, ) -from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + assert_setup_component, + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -493,3 +505,457 @@ async def test_forecast_format_error( return_response=True, ) assert "Forecast in list is not a dict, see Weather documentation" in caplog.text + + +SAVED_EXTRA_DATA = { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_forecast": None, + "last_humidity": 10, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, +} + +SAVED_EXTRA_DATA_WITH_FUTURE_KEY = { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_forecast": None, + "last_humidity": 10, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, + "some_key_added_in_the_future": 123, +} + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ], +) +@pytest.mark.parametrize( + ("saved_state", "saved_extra_data", "initial_state"), + [ + ("sunny", SAVED_EXTRA_DATA, "sunny"), + ("sunny", SAVED_EXTRA_DATA_WITH_FUTURE_KEY, "sunny"), + (STATE_UNAVAILABLE, SAVED_EXTRA_DATA, STATE_UNKNOWN), + (STATE_UNKNOWN, SAVED_EXTRA_DATA, STATE_UNKNOWN), + ], +) +async def test_trigger_entity_restore_state( + hass: HomeAssistant, + count: int, + domain: str, + config: dict, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, +) -> None: + """Test restoring trigger template weather.""" + + restored_attributes = { # These should be ignored + "temperature": -10, + "humidity": 50, + } + + fake_state = State( + "weather.test", + saved_state, + restored_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((fake_state, saved_extra_data),)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == initial_state + + hass.bus.async_fire( + "test_event", {"condition": "cloudy", "temperature": 15, "humidity": 25} + ) + await hass.async_block_till_done() + state = hass.states.get("weather.test") + + state = hass.states.get("weather.test") + assert state.state == "cloudy" + assert state.attributes["temperature"] == 15.0 + assert state.attributes["humidity"] == 25.0 + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.temperature + 1 }}" + }, + }, + ], + "weather": [ + { + "name": "Hello Name", + "condition_template": "sunny", + "temperature_unit": "°C", + "humidity_template": "{{ 20 }}", + "temperature_template": "{{ my_variable + 1 }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_action( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity with an action works.""" + state = hass.states.get("weather.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"temperature": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("weather.hello_name") + assert state.state == "sunny" + assert state.attributes["temperature"] == 3.0 + assert state.context is context + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.information + 1 }}", + "var_forecast_daily": "{{ trigger.event.data.forecast_daily }}", + "var_forecast_hourly": "{{ trigger.event.data.forecast_hourly }}", + "var_forecast_twice_daily": "{{ trigger.event.data.forecast_twice_daily }}", + }, + }, + ], + "weather": [ + { + "name": "Test", + "condition_template": "sunny", + "precipitation_unit": "mm", + "pressure_unit": "hPa", + "visibility_unit": "km", + "wind_speed_unit": "km/h", + "temperature_unit": "°C", + "temperature_template": "{{ my_variable + 1 }}", + "humidity_template": "{{ my_variable + 1 }}", + "wind_speed_template": "{{ my_variable + 1 }}", + "wind_bearing_template": "{{ my_variable + 1 }}", + "ozone_template": "{{ my_variable + 1 }}", + "visibility_template": "{{ my_variable + 1 }}", + "pressure_template": "{{ my_variable + 1 }}", + "wind_gust_speed_template": "{{ my_variable + 1 }}", + "cloud_coverage_template": "{{ my_variable + 1 }}", + "dew_point_template": "{{ my_variable + 1 }}", + "apparent_temperature_template": "{{ my_variable + 1 }}", + "forecast_template": "{{ var_forecast_daily }}", + "forecast_daily_template": "{{ var_forecast_daily }}", + "forecast_hourly_template": "{{ var_forecast_hourly }}", + "forecast_twice_daily_template": "{{ var_forecast_twice_daily }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_weather_services( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger weather entity with services.""" + state = hass.states.get("weather.test") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + now = dt_util.now().isoformat() + hass.bus.async_fire( + "test_event", + { + "information": 1, + "forecast_daily": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + } + ], + "forecast_hourly": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + } + ], + "forecast_twice_daily": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20, + "temperature": 20, + "templow": 15, + "is_daytime": True, + } + ], + }, + context=context, + ) + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == "sunny" + assert state.attributes["temperature"] == 3.0 + assert state.attributes["humidity"] == 3.0 + assert state.attributes["wind_speed"] == 3.0 + assert state.attributes["wind_bearing"] == 3.0 + assert state.attributes["ozone"] == 3.0 + assert state.attributes["visibility"] == 3.0 + assert state.attributes["pressure"] == 3.0 + assert state.attributes["wind_gust_speed"] == 3.0 + assert state.attributes["cloud_coverage"] == 3.0 + assert state.attributes["dew_point"] == 3.0 + assert state.attributes["apparent_temperature"] == 3.0 + assert state.context is context + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + } + ], + } + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + } + ], + } + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": state.entity_id, + "type": "twice_daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "datetime": now, + "condition": "sunny", + "precipitation": 20.0, + "temperature": 20.0, + "templow": 15.0, + "is_daytime": True, + } + ], + } + + +async def test_restore_weather_save_state( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test Restore saved state for Weather trigger template.""" + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.bus.async_fire( + "test_event", {"condition": "cloudy", "temperature": 15, "humidity": 25} + ) + await hass.async_block_till_done() + entity = hass.states.get("weather.test") + + # Trigger saving state + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == { + "last_apparent_temperature": None, + "last_cloud_coverage": None, + "last_dew_point": None, + "last_humidity": "25.0", + "last_ozone": None, + "last_pressure": None, + "last_temperature": "15.0", + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, + } + + +SAVED_ATTRIBUTES_1 = { + "humidity": 20, + "temperature": 10, +} + +SAVED_EXTRA_DATA_MISSING_KEY = { + "last_cloud_coverage": None, + "last_dew_point": None, + "last_humidity": 20, + "last_ozone": None, + "last_pressure": None, + "last_temperature": 20, + "last_visibility": None, + "last_wind_bearing": None, + "last_wind_gust_speed": None, + "last_wind_speed": None, +} + + +@pytest.mark.parametrize( + ("saved_attributes", "saved_extra_data"), + [ + (SAVED_ATTRIBUTES_1, SAVED_EXTRA_DATA_MISSING_KEY), + (SAVED_ATTRIBUTES_1, None), + ], +) +async def test_trigger_entity_restore_state_fail( + hass: HomeAssistant, + saved_attributes: dict, + saved_extra_data: dict | None, +) -> None: + """Test restoring trigger template weather fails due to missing attribute.""" + + saved_state = State( + "weather.test", + None, + saved_attributes, + ) + mock_restore_cache_with_extra_data(hass, ((saved_state, saved_extra_data),)) + assert await async_setup_component( + hass, + "template", + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "weather": { + "name": "test", + "condition_template": "{{ trigger.event.data.condition }}", + "temperature_template": "{{ trigger.event.data.temperature | float }}", + "temperature_unit": "°C", + "humidity_template": "{{ trigger.event.data.humidity | float }}", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("weather.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get("temperature") is None From 134c005168c4a661438caa1705fca8a221455461 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 27 Sep 2023 10:14:51 +0200 Subject: [PATCH 873/984] Add typing to poolsense (#100984) --- .strict-typing | 1 + homeassistant/components/poolsense/binary_sensor.py | 2 +- homeassistant/components/poolsense/config_flow.py | 6 +++++- homeassistant/components/poolsense/coordinator.py | 10 ++++++---- homeassistant/components/poolsense/entity.py | 10 ++++++++-- homeassistant/components/poolsense/sensor.py | 3 ++- mypy.ini | 10 ++++++++++ 7 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.strict-typing b/.strict-typing index 439831790b0..6b2c52f42f6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -261,6 +261,7 @@ homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.plugwise.* +homeassistant.components.poolsense.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* homeassistant.components.proximity.* diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index 56e417511bd..052a205a37b 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -48,6 +48,6 @@ class PoolSenseBinarySensor(PoolSenseEntity, BinarySensorEntity): """Representation of PoolSense binary sensors.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.coordinator.data[self.entity_description.key] == "red" diff --git a/homeassistant/components/poolsense/config_flow.py b/homeassistant/components/poolsense/config_flow.py index 6a6708b4045..64685d67035 100644 --- a/homeassistant/components/poolsense/config_flow.py +++ b/homeassistant/components/poolsense/config_flow.py @@ -1,11 +1,13 @@ """Config flow for PoolSense integration.""" import logging +from typing import Any from poolsense import PoolSense 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 @@ -21,7 +23,9 @@ class PoolSenseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize PoolSense config flow.""" - 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 = {} diff --git a/homeassistant/components/poolsense/coordinator.py b/homeassistant/components/poolsense/coordinator.py index 3a5089b5022..e5e3e6ad1bd 100644 --- a/homeassistant/components/poolsense/coordinator.py +++ b/homeassistant/components/poolsense/coordinator.py @@ -6,8 +6,11 @@ import logging from poolsense import PoolSense from poolsense.exceptions import PoolSenseError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -15,10 +18,10 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): +class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, StateType]]): """Define an object to hold PoolSense data.""" - def __init__(self, hass, entry): + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" self.poolsense = PoolSense( aiohttp_client.async_get_clientsession(hass), @@ -26,11 +29,10 @@ class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): entry.data[CONF_PASSWORD], ) self.hass = hass - self.entry = entry super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, StateType]: """Update data via library.""" data = {} async with asyncio.timeout(10): diff --git a/homeassistant/components/poolsense/entity.py b/homeassistant/components/poolsense/entity.py index 2186d815135..0eca39cc48d 100644 --- a/homeassistant/components/poolsense/entity.py +++ b/homeassistant/components/poolsense/entity.py @@ -3,14 +3,20 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION +from .coordinator import PoolSenseDataUpdateCoordinator -class PoolSenseEntity(CoordinatorEntity): +class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]): """Implements a common class elements representing the PoolSense component.""" _attr_attribution = ATTRIBUTION - def __init__(self, coordinator, email, description: EntityDescription) -> None: + def __init__( + self, + coordinator: PoolSenseDataUpdateCoordinator, + email: str, + description: EntityDescription, + ) -> None: """Initialize poolsense sensor.""" super().__init__(coordinator) self.entity_description = description diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index eee7518e6da..ed120562374 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .entity import PoolSenseEntity @@ -93,6 +94,6 @@ class PoolSenseSensor(PoolSenseEntity, SensorEntity): """Sensor representing poolsense data.""" @property - def native_value(self): + def native_value(self) -> StateType: """State of the sensor.""" return self.coordinator.data[self.entity_description.key] diff --git a/mypy.ini b/mypy.ini index f18e781fd23..c2ecac66946 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2372,6 +2372,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.poolsense.*] +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.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true From 01b58549685da5e9f4c25f1d4817b2f9ab6afba4 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 27 Sep 2023 10:56:24 +0200 Subject: [PATCH 874/984] Rework UniFi websocket (#100614) * Rework websocket management * remove unnecessary fixture * Remove controller from mock_unifi_websocket * Mock api.login in reconnect method * Remove unnecessary edits * Minor clean up * Bump aiounifi to v63 * Wait on task cancellation --- homeassistant/components/unifi/__init__.py | 2 +- homeassistant/components/unifi/controller.py | 55 +++++---- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/conftest.py | 105 +++++++++++++----- tests/components/unifi/test_button.py | 10 +- tests/components/unifi/test_controller.py | 42 ++----- tests/components/unifi/test_device_tracker.py | 13 +-- tests/components/unifi/test_image.py | 8 +- tests/components/unifi/test_sensor.py | 31 +++--- tests/components/unifi/test_switch.py | 52 ++++----- tests/components/unifi/test_update.py | 17 +-- 13 files changed, 184 insertions(+), 157 deletions(-) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 0bde41ac611..4337899a50f 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if len(hass.data[UNIFI_DOMAIN]) == 1: async_setup_services(hass) - api.start_websocket() + controller.start_websocket() config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 9f965b424ff..620b928176e 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -12,7 +12,6 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.models.configuration import Configuration from aiounifi.models.device import DeviceSetPoePortModeRequest -from aiounifi.websocket import WebsocketState from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -81,7 +80,7 @@ class UniFiController: self.config_entry = config_entry self.api = api - api.ws_state_callback = self.async_unifi_ws_state_callback + self.ws_task: asyncio.Task | None = None self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] @@ -223,23 +222,6 @@ class UniFiController: for description in descriptions: async_load_entities(description) - @callback - def async_unifi_ws_state_callback(self, state: WebsocketState) -> None: - """Handle messages back from UniFi library.""" - if state == WebsocketState.DISCONNECTED and self.available: - LOGGER.warning("Lost connection to UniFi Network") - - if (state == WebsocketState.RUNNING and not self.available) or ( - state == WebsocketState.DISCONNECTED and self.available - ): - self.available = state == WebsocketState.RUNNING - async_dispatcher_send(self.hass, self.signal_reachable) - - if not self.available: - self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) - else: - LOGGER.info("Connected to UniFi Network") - @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" @@ -367,6 +349,19 @@ class UniFiController: controller.load_config_entry_options() async_dispatcher_send(hass, controller.signal_options_update) + @callback + def start_websocket(self) -> None: + """Start up connection to websocket.""" + + async def _websocket_runner() -> None: + """Start websocket.""" + await self.api.start_websocket() + self.available = False + async_dispatcher_send(self.hass, self.signal_reachable) + self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) + + self.ws_task = self.hass.loop.create_task(_websocket_runner()) + @callback def reconnect(self, log: bool = False) -> None: """Prepare to reconnect UniFi session.""" @@ -379,7 +374,11 @@ class UniFiController: try: async with asyncio.timeout(5): await self.api.login() - self.api.start_websocket() + self.start_websocket() + + if not self.available: + self.available = True + async_dispatcher_send(self.hass, self.signal_reachable) except ( asyncio.TimeoutError, @@ -395,7 +394,8 @@ class UniFiController: Used as an argument to EventBus.async_listen_once. """ - self.api.stop_websocket() + if self.ws_task is not None: + self.ws_task.cancel() async def async_reset(self) -> bool: """Reset this controller to default state. @@ -403,7 +403,18 @@ class UniFiController: Will cancel any scheduled setup retry and will unload the config entry. """ - self.api.stop_websocket() + if self.ws_task is not None: + self.ws_task.cancel() + + _, pending = await asyncio.wait([self.ws_task], timeout=10) + + if pending: + LOGGER.warning( + "Unloading %s (%s) config entry. Task %s did not complete in time", + self.config_entry.title, + self.config_entry.domain, + self.ws_task, + ) unload_ok = await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 8734fd7dce5..7673402aaac 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==62"], + "requirements": ["aiounifi==63"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 45bdb08b7fb..013c02c2d2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -363,7 +363,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==62 +aiounifi==63 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e78e1d2658d..478db5bffeb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -338,7 +338,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==62 +aiounifi==63 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index ca0c855d1ab..d48ff613902 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -1,47 +1,100 @@ """Fixtures for UniFi Network methods.""" from __future__ import annotations +import asyncio +from datetime import timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketSignal, WebsocketState import pytest +from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN +from homeassistant.components.unifi.controller import RETRY_TIMER +from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.unifi.test_controller import DEFAULT_CONFIG_ENTRY_ID +from tests.test_util.aiohttp import AiohttpClientMocker + + +class WebsocketStateManager(asyncio.Event): + """Keep an async event that simules websocket context manager. + + Prepares disconnect and reconnect flows. + """ + + def __init__(self, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Store hass object and initialize asyncio.Event.""" + self.hass = hass + self.aioclient_mock = aioclient_mock + super().__init__() + + async def disconnect(self): + """Mark future as done to make 'await self.api.start_websocket' return.""" + self.set() + await self.hass.async_block_till_done() + + async def reconnect(self, fail=False): + """Set up new future to make 'await self.api.start_websocket' block. + + Mock api calls done by 'await self.api.login'. + Fail will make 'await self.api.start_websocket' return immediately. + """ + controller = self.hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID] + self.aioclient_mock.get( + f"https://{controller.host}:1234", status=302 + ) # Check UniFi OS + self.aioclient_mock.post( + f"https://{controller.host}:1234/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + if not fail: + self.clear() + new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) + async_fire_time_changed(self.hass, new_time) + await self.hass.async_block_till_done() @pytest.fixture(autouse=True) -def mock_unifi_websocket(): - """No real websocket allowed.""" - with patch("aiounifi.controller.WSClient") as mock: +def websocket_mock(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Mock 'await self.api.start_websocket' in 'UniFiController.start_websocket'.""" + websocket_state_manager = WebsocketStateManager(hass, aioclient_mock) + with patch("aiounifi.Controller.start_websocket") as ws_mock: + ws_mock.side_effect = websocket_state_manager.wait + yield websocket_state_manager - def make_websocket_call( - *, - message: MessageKey | None = None, - data: list[dict] | dict | None = None, - state: WebsocketState | None = None, - ): - """Generate a websocket call.""" - if data and not message: - mock.return_value.data = data - mock.call_args[1]["callback"](WebsocketSignal.DATA) - elif data and message: - if not isinstance(data, list): - data = [data] - mock.return_value.data = { + +@pytest.fixture(autouse=True) +def mock_unifi_websocket(hass): + """No real websocket allowed.""" + + def make_websocket_call( + *, + message: MessageKey | None = None, + data: list[dict] | dict | None = None, + ): + """Generate a websocket call.""" + controller = hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID] + if data and not message: + controller.api.messages.handler(data) + elif data and message: + if not isinstance(data, list): + data = [data] + controller.api.messages.handler( + { "meta": {"message": message.value}, "data": data, } - mock.call_args[1]["callback"](WebsocketSignal.DATA) - elif state: - mock.return_value.state = state - mock.call_args[1]["callback"](WebsocketSignal.CONNECTION_STATE) - else: - raise NotImplementedError + ) + else: + raise NotImplementedError - yield make_websocket_call + return make_websocket_call @pytest.fixture(autouse=True) diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 0c6ac38739e..30a1b3e08ff 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -1,7 +1,5 @@ """UniFi Network button platform tests.""" -from aiounifi.websocket import WebsocketState - from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory @@ -14,7 +12,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_restart_device_button( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Test restarting device button.""" config_entry = await setup_unifi_integration( @@ -71,11 +69,9 @@ async def test_restart_device_button( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("button.switch_restart").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index f4738862aef..93b39d2fdf2 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -6,7 +6,6 @@ from http import HTTPStatus from unittest.mock import Mock, patch import aiounifi -from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN @@ -28,7 +27,7 @@ from homeassistant.components.unifi.const import ( PLATFORMS, UNIFI_WIRELESS_CLIENTS, ) -from homeassistant.components.unifi.controller import RETRY_TIMER, get_unifi_controller +from homeassistant.components.unifi.controller import get_unifi_controller from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect from homeassistant.const import ( CONF_HOST, @@ -44,7 +43,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker DEFAULT_CONFIG_ENTRY_ID = "1" @@ -365,8 +364,8 @@ async def test_reset_fails( async def test_connection_state_signalling( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, mock_device_registry, + websocket_mock, ) -> None: """Verify connection statesignalling and connection state are working.""" client = { @@ -381,21 +380,17 @@ async def test_connection_state_signalling( # Controller is connected assert hass.states.get("device_tracker.client").state == "home" - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() # Controller is disconnected assert hass.states.get("device_tracker.client").state == "unavailable" - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() # Controller is once again connected assert hass.states.get("device_tracker.client").state == "home" async def test_reconnect_mechanism( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Verify reconnect prints only on first reconnection try.""" await setup_unifi_integration(hass, aioclient_mock) @@ -403,21 +398,13 @@ async def test_reconnect_mechanism( aioclient_mock.clear_requests() aioclient_mock.get(f"https://{DEFAULT_HOST}:1234/", status=HTTPStatus.BAD_GATEWAY) - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert aioclient_mock.call_count == 0 - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - + await websocket_mock.reconnect(fail=True) assert aioclient_mock.call_count == 1 - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - + await websocket_mock.reconnect(fail=True) assert aioclient_mock.call_count == 2 @@ -431,10 +418,7 @@ async def test_reconnect_mechanism( ], ) async def test_reconnect_mechanism_exceptions( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - exception, + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock, exception ) -> None: """Verify async_reconnect calls expected methods.""" await setup_unifi_integration(hass, aioclient_mock) @@ -442,11 +426,9 @@ async def test_reconnect_mechanism_exceptions( with patch("aiounifi.Controller.login", side_effect=exception), patch( "homeassistant.components.unifi.controller.UniFiController.reconnect" ) as mock_reconnect: - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(hass, new_time) + await websocket_mock.reconnect() mock_reconnect.assert_called_once() diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 99874b3a949..2680a357d77 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -3,7 +3,6 @@ from datetime import timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from freezegun.api import FrozenDateTimeFactory from homeassistant import config_entries @@ -40,8 +39,8 @@ async def test_no_entities( async def test_tracked_wireless_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, mock_device_registry, + mock_unifi_websocket, ) -> None: """Verify tracking of wireless clients.""" client = { @@ -402,7 +401,7 @@ async def test_remove_clients( async def test_controller_state_change( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + websocket_mock, mock_device_registry, ) -> None: """Verify entities state reflect on controller becoming unavailable.""" @@ -443,16 +442,12 @@ async def test_controller_state_change( assert hass.states.get("device_tracker.device").state == STATE_HOME # Controller unavailable - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert hass.states.get("device_tracker.client").state == STATE_UNAVAILABLE assert hass.states.get("device_tracker.device").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device").state == STATE_HOME diff --git a/tests/components/unifi/test_image.py b/tests/components/unifi/test_image.py index 38a8cef43c1..92879f5ad14 100644 --- a/tests/components/unifi/test_image.py +++ b/tests/components/unifi/test_image.py @@ -5,7 +5,6 @@ from datetime import timedelta from http import HTTPStatus from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from syrupy.assertion import SnapshotAssertion from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN @@ -65,6 +64,7 @@ async def test_wlan_qr_code( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_clients function when no clients are found.""" await setup_unifi_integration(hass, aioclient_mock, wlans_response=[WLAN]) @@ -121,13 +121,11 @@ async def test_wlan_qr_code( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("image.ssid_1_qr_code").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE # WLAN gets disabled diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 7b6a3bc1edc..b652c38abdb 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -562,7 +561,10 @@ async def test_remove_sensors( async def test_poe_port_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) @@ -607,16 +609,16 @@ async def test_poe_port_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert ( hass.states.get("sensor.mock_name_port_1_poe_power").state == STATE_UNAVAILABLE ) # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - assert hass.states.get("sensor.mock_name_port_1_poe_power") + await websocket_mock.reconnect() + assert ( + hass.states.get("sensor.mock_name_port_1_poe_power").state != STATE_UNAVAILABLE + ) # Device gets disabled device_1["disabled"] = True @@ -634,7 +636,10 @@ async def test_poe_port_switches( async def test_wlan_client_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Verify that WLAN client sensors are working as expected.""" wireless_client_1 = { @@ -720,13 +725,11 @@ async def test_wlan_client_sensors( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("sensor.ssid_1").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("sensor.ssid_1").state == "0" # WLAN gets disabled @@ -837,7 +840,6 @@ async def test_device_uptime( now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" @@ -854,7 +856,6 @@ async def test_device_uptime( now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): mock_unifi_websocket(message=MessageKey.DEVICE, data=device) - await hass.async_block_till_done() assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" @@ -865,7 +866,6 @@ async def test_device_uptime( now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): mock_unifi_websocket(message=MessageKey.DEVICE, data=device) - await hass.async_block_till_done() assert hass.states.get("sensor.device_uptime").state == "2021-02-01T01:00:00+00:00" @@ -908,5 +908,4 @@ async def test_device_temperature( # Verify new event change temperature device["general_temperature"] = 60 mock_unifi_websocket(message=MessageKey.DEVICE, data=device) - await hass.async_block_till_done() assert hass.states.get("sensor.device_temperature").state == "60" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 8e536119291..a08cf0be688 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -3,7 +3,6 @@ from copy import deepcopy from datetime import timedelta from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.switch import ( @@ -1001,7 +1000,10 @@ async def test_block_switches( async def test_dpi_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration( @@ -1026,13 +1028,11 @@ async def test_dpi_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.block_media_streaming").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.block_media_streaming").state == STATE_OFF # Remove app @@ -1128,6 +1128,7 @@ async def test_outlet_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, + websocket_mock, entity_id: str, test_data: any, outlet_index: int, @@ -1192,13 +1193,11 @@ async def test_outlet_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Device gets disabled @@ -1320,7 +1319,10 @@ async def test_option_remove_switches( async def test_poe_port_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test the update_items function with some clients.""" config_entry = await setup_unifi_integration( @@ -1408,13 +1410,11 @@ async def test_poe_port_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF # Device gets disabled @@ -1431,7 +1431,10 @@ async def test_poe_port_switches( async def test_wlan_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test control of UniFi WLAN availability.""" config_entry = await setup_unifi_integration( @@ -1488,18 +1491,19 @@ async def test_wlan_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.ssid_1").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.ssid_1").state == STATE_OFF async def test_port_forwarding_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, ) -> None: """Test control of UniFi port forwarding.""" _data = { @@ -1570,13 +1574,11 @@ async def test_port_forwarding_switches( # Availability signalling # Controller disconnects - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() + await websocket_mock.disconnect() assert hass.states.get("switch.unifi_network_plex").state == STATE_UNAVAILABLE # Controller reconnects - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() + await websocket_mock.reconnect() assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF # Remove entity on deleted message diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index e59eca371d6..4f7a3dfe11d 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -2,7 +2,6 @@ from copy import deepcopy from aiounifi.models.message import MessageKey -from aiounifi.websocket import WebsocketState from yarl import URL from homeassistant.components.unifi.const import CONF_SITE_ID @@ -185,26 +184,18 @@ async def test_install( async def test_controller_state_change( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock ) -> None: """Verify entities state reflect on controller becoming unavailable.""" - await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[DEVICE_1], - ) + await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 1 assert hass.states.get("update.device_1").state == STATE_ON # Controller unavailable - mock_unifi_websocket(state=WebsocketState.DISCONNECTED) - await hass.async_block_till_done() - + await websocket_mock.disconnect() assert hass.states.get("update.device_1").state == STATE_UNAVAILABLE # Controller available - mock_unifi_websocket(state=WebsocketState.RUNNING) - await hass.async_block_till_done() - + await websocket_mock.reconnect() assert hass.states.get("update.device_1").state == STATE_ON From 96151e7faa2a9cf12ec82c68b110bd167b7a7ad8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 27 Sep 2023 13:32:30 +0200 Subject: [PATCH 875/984] Use local time instead of UTC time as default backup filenames (#100959) Use local time instead of UTC for the backup name --- homeassistant/components/hassio/__init__.py | 4 ++-- tests/components/hassio/test_init.py | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 270309149ef..3303059d824 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -49,7 +49,7 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.loader import bind_hass -from homeassistant.util.dt import utcnow +from homeassistant.util.dt import now from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401 from .addon_panel import async_setup_addon_panel @@ -177,7 +177,7 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( SCHEMA_BACKUP_FULL = vol.Schema( { vol.Optional( - ATTR_NAME, default=lambda: utcnow().strftime("%Y-%m-%d %H:%M:%S") + ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") ): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_COMPRESSED): cv.boolean, diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 48f52ee7c24..adb462b02e3 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -548,7 +548,7 @@ async def test_service_calls( assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { - "name": "2021-11-13 11:48:00", + "name": "2021-11-13 03:48:00", "homeassistant": True, "addons": ["test"], "folders": ["ssl"], @@ -605,6 +605,24 @@ async def test_service_calls( await hass.async_block_till_done() assert aioclient_mock.call_count == 34 + assert aioclient_mock.mock_calls[-1][2] == { + "name": "2021-11-13 03:48:00", + "location": None, + } + + # check backup with different timezone + await hass.config.async_update(time_zone="Europe/London") + + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "/backup", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 36 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, From f232ddb85ed2c73ec0067146c61e8c83ed2a097d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 27 Sep 2023 13:57:28 +0200 Subject: [PATCH 876/984] Bump py-dormakaba-dkey to 1.0.5 (#100992) --- homeassistant/components/dormakaba_dkey/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 7a4f6b9d905..52e68b7521c 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.4"] + "requirements": ["py-dormakaba-dkey==1.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 013c02c2d2a..edd19f6b96f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1502,7 +1502,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.4 +py-dormakaba-dkey==1.0.5 # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 478db5bffeb..125582078e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1147,7 +1147,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.4 +py-dormakaba-dkey==1.0.5 # homeassistant.components.melissa py-melissa-climate==2.1.4 From 03827bda1e1d2c74c80507e14519daff6241fb9e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Sep 2023 14:13:11 +0200 Subject: [PATCH 877/984] Use async_at_started in Withings (#100994) * Use async_at_started in Withings * Make nice --- homeassistant/components/withings/__init__.py | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index ec7d96ec2fa..7b6a56995c8 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at from __future__ import annotations from collections.abc import Awaitable, Callable -from datetime import datetime +from typing import Any from aiohttp.hdrs import METH_HEAD, METH_POST from aiohttp.web import Request, Response @@ -30,20 +30,14 @@ from homeassistant.const import ( CONF_CLIENT_SECRET, CONF_TOKEN, CONF_WEBHOOK_ID, - EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - CoreState, - Event, - HomeAssistant, - ServiceCall, -) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi @@ -135,14 +129,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator async def unregister_webhook( - call_or_event_or_dt: ServiceCall | Event | datetime | None, + _: Any, ) -> None: LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await hass.data[DOMAIN][entry.entry_id].async_unsubscribe_webhooks() async def register_webhook( - call_or_event_or_dt: ServiceCall | Event | datetime | None, + _: Any, ) -> None: if cloud.async_active_subscription(hass): webhook_url = await async_cloudhook_generate_url(hass, entry) @@ -182,11 +176,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if cloud.async_is_connected(hass): await register_webhook(None) cloud.async_listen_connection_change(hass, manage_cloudhook) - - elif hass.state == CoreState.running: - await register_webhook(None) else: - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, register_webhook) + async_at_started(hass, register_webhook) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) From 7cb555739f554523dfe1a183d80db3a801210746 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Sep 2023 14:48:03 +0200 Subject: [PATCH 878/984] Exclude manifest files from youtube media extraction (#100771) * Exclude manifest files from youtube media extraction * Simplify * Fix --- .../components/media_extractor/__init__.py | 28 +++++++++++++++---- .../media_extractor/snapshots/test_init.ambr | 6 ++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index dae734fc06f..c6f899c4909 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -135,11 +135,10 @@ class MediaExtractor: raise MEQueryException() from err if "formats" in requested_stream: - best_stream = requested_stream["formats"][ - len(requested_stream["formats"]) - 1 - ] - return str(best_stream["url"]) - return str(requested_stream["url"]) + if requested_stream["extractor"] == "youtube": + return get_best_stream_youtube(requested_stream["formats"]) + return get_best_stream(requested_stream["formats"]) + return cast(str, requested_stream["url"]) return stream_selector @@ -181,3 +180,22 @@ class MediaExtractor: ) return default_stream_query + + +def get_best_stream(formats: list[dict[str, Any]]) -> str: + """Return the best quality stream. + + As per + https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/common.py#L128. + """ + + return cast(str, formats[len(formats) - 1]["url"]) + + +def get_best_stream_youtube(formats: list[dict[str, Any]]) -> str: + """YouTube requests also include manifest files. + + They don't have a filesize so we skip all formats without filesize. + """ + + return get_best_stream([format for format in formats if "filesize" in format]) diff --git a/tests/components/media_extractor/snapshots/test_init.ambr b/tests/components/media_extractor/snapshots/test_init.ambr index 571b64df914..56162ca3040 100644 --- a/tests/components/media_extractor/snapshots/test_init.ambr +++ b/tests/components/media_extractor/snapshots/test_init.ambr @@ -6,7 +6,7 @@ ]), 'extra': dict({ }), - 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8', + 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJ-5AjGgFTR1w-qObfMtwCvs07CU5OUDG7bsNqAXrZMxAiEA4pJO9wj-ZQTqFHg5OP2_XZIJbog8NvY8BVSwENMwJfM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb', 'media_content_type': 'VIDEO', }) # --- @@ -87,7 +87,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805268/ei/tFgEZcu0DoOD-gaqg47wBA/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,29/mn/sn-5hne6nzy,sn-5hnekn7k/ms/au,rdu/mv/m/mvi/3/pl/22/initcwndbps/1957500/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783146/fvip/2/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhALAASH0_ZDQQoMA82qWNCXSHPZ0bb9TQldIs7AAxktiiAiASA5bQy7IAa6NwdGIOpfye5OgcY_BNuo0WgSdh84tosw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIsDcLGH8KJpQpBgyJ5VWlDxfr75HyO8hMSVS9v7nRu4AiEA2xjtLZOzeNFoJlxwCsH3YqsUQt-BF_4gikhi_P4FbBc%3D/playlist/index.m3u8', + 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgT7VwysCFd3nXvaSSiJoVxkNj5jfMPSeitLsQmy_S1b4CIQDWFiZSIH3tV4hQRtHa9DbzdYL8RQpbKD_6aeNZ7t-3IA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l', 'media_content_type': 'VIDEO', }) # --- @@ -105,7 +105,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1694805294/ei/zlgEZcCPFpqOx_APj42f2Ao/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-aigzrnld/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/2095000/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1694783390/fvip/1/short_key/1/keepalive/yes/fexp/24007246,24362685/beids/24350017/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhANCPwWNfq6wBp1Xo1L8bRJpDrzOyv7kfH_J65cZ_PRZLAiEAwo-0wQgeIjPe7OgyAAvMCx_A9wd1h8Qyh7VntKwGJUs%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIqS9Ub_6L9ScKXr0T9bkeu6TZsEsyNApYfF_MqeukqECIQCMSeJ1sSEw5QGMgHAW8Fhsir4TYHEK5KVg-PzJbrT6hw%3D%3D/playlist/index.m3u8', + 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJ-5AjGgFTR1w-qObfMtwCvs07CU5OUDG7bsNqAXrZMxAiEA4pJO9wj-ZQTqFHg5OP2_XZIJbog8NvY8BVSwENMwJfM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb', 'media_content_type': 'VIDEO', }) # --- From 92694c53e09cd55ac48543cdded0bdcf89e2ef76 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 27 Sep 2023 09:02:19 -0400 Subject: [PATCH 879/984] Increase MyQ update interval (#100977) --- homeassistant/components/myq/const.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py index 3c7b5ba373a..16dead34477 100644 --- a/homeassistant/components/myq/const.py +++ b/homeassistant/components/myq/const.py @@ -33,14 +33,4 @@ MYQ_TO_HASS = { MYQ_GATEWAY = "myq_gateway" MYQ_COORDINATOR = "coordinator" -# myq has some ratelimits in place -# and 61 seemed to be work every time -UPDATE_INTERVAL = 15 - -# Estimated time it takes myq to start transition from one -# state to the next. -TRANSITION_START_DURATION = 7 - -# Estimated time it takes myq to complete a transition -# from one state to another -TRANSITION_COMPLETE_DURATION = 37 +UPDATE_INTERVAL = 30 From 91fcbb41b0d5f0666d64c0a6d6d770875afee6af Mon Sep 17 00:00:00 2001 From: amitfin Date: Wed, 27 Sep 2023 16:13:38 +0300 Subject: [PATCH 880/984] Skip timestamp check of the SIA events (#100660) --- homeassistant/components/sia/hub.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 859841d3bea..9ba7a19a9be 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -28,7 +28,6 @@ from .utils import get_event_data_from_sia_event _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEBAND = (80, 40) -IGNORED_TIMEBAND = (3600, 1800) class SIAHub: @@ -100,7 +99,7 @@ class SIAHub: SIAAccount( account_id=a[CONF_ACCOUNT], key=a.get(CONF_ENCRYPTION_KEY), - allowed_timeband=IGNORED_TIMEBAND + allowed_timeband=None if a[CONF_IGNORE_TIMESTAMPS] else DEFAULT_TIMEBAND, ) From 25a80cd46f0c51f675294f859ee797d9081989a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Sep 2023 15:45:52 +0200 Subject: [PATCH 881/984] Add config flow to Twitch (#93451) * Update twitch API * Update twitchAPI * Add tests * Apply suggestions from code review Co-authored-by: Franck Nijhof * Update sensor.py * Update sensor.py * Update sensor.py * Update sensor.py * Update sensor.py * Fix coverage * Move Twitch constants to separate file * Move Twitch constants to separate file * Move Twitch constants to separate file * Add application credentials * Add config flow * Try to add tests * Add strings * Add tests * Add tests * Improve tests * Fix tests * Extract Twitch client creation * Fix reauth * Remove import flow * Remove import flow * Remove reauth * Update * Fix Ruff * Fix feedback * Add strings * Add reauth * Do stuff in init * Fix stuff * Fix stuff * Fix stuff * Fix stuff * Fix stuff * Start with tests * Test coverage * Test coverage * Remove strings * Cleanup * Fix feedback * Fix feedback --------- Co-authored-by: Franck Nijhof --- homeassistant/components/twitch/__init__.py | 52 +++ .../twitch/application_credentials.py | 14 + .../components/twitch/config_flow.py | 189 +++++++++++ homeassistant/components/twitch/const.py | 12 +- homeassistant/components/twitch/manifest.json | 4 +- homeassistant/components/twitch/sensor.py | 93 +++--- homeassistant/components/twitch/strings.json | 30 ++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/twitch/__init__.py | 150 ++++++--- tests/components/twitch/conftest.py | 110 +++++++ tests/components/twitch/test_config_flow.py | 295 ++++++++++++++++++ tests/components/twitch/test_init.py | 116 +++++++ tests/components/twitch/test_sensor.py | 177 +++++++++++ tests/components/twitch/test_twitch.py | 205 ------------ 18 files changed, 1155 insertions(+), 300 deletions(-) create mode 100644 homeassistant/components/twitch/application_credentials.py create mode 100644 homeassistant/components/twitch/config_flow.py create mode 100644 homeassistant/components/twitch/strings.json create mode 100644 tests/components/twitch/conftest.py create mode 100644 tests/components/twitch/test_config_flow.py create mode 100644 tests/components/twitch/test_init.py create mode 100644 tests/components/twitch/test_sensor.py delete mode 100644 tests/components/twitch/test_twitch.py diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index 64feb17d6b5..76b6ec709ff 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -1 +1,53 @@ """The Twitch component.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError, ClientResponseError +from twitchAPI.twitch import Twitch + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Twitch from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + 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 + + app_id = implementation.__dict__[CONF_CLIENT_ID] + access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + client = await Twitch( + app_id=app_id, + authenticate_app=False, + ) + client.auto_refresh_auth = False + await client.set_user_authentication(access_token, scope=OAUTH_SCOPES) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Twitch config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/twitch/application_credentials.py b/homeassistant/components/twitch/application_credentials.py new file mode 100644 index 00000000000..fd8b03db2ca --- /dev/null +++ b/homeassistant/components/twitch/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Twitch integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(_: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py new file mode 100644 index 00000000000..9e586b19a5a --- /dev/null +++ b/homeassistant/components/twitch/config_flow.py @@ -0,0 +1,189 @@ +"""Config flow for Twitch.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from twitchAPI.helper import first +from twitchAPI.twitch import Twitch +from twitchAPI.type import AuthScope, InvalidTokenException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_TOKEN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import CONF_CHANNELS, CONF_REFRESH_TOKEN, DOMAIN, LOGGER, OAUTH_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Twitch OAuth2 authentication.""" + + DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None + + def __init__(self) -> None: + """Initialize flow.""" + super().__init__() + self.data: dict[str, Any] = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return LOGGER + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join([scope.value for scope in OAUTH_SCOPES])} + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> FlowResult: + """Handle the initial step.""" + + client = await Twitch( + app_id=self.flow_impl.__dict__[CONF_CLIENT_ID], + authenticate_app=False, + ) + client.auto_refresh_auth = False + await client.set_user_authentication( + data[CONF_TOKEN][CONF_ACCESS_TOKEN], scope=OAUTH_SCOPES + ) + user = await first(client.get_users()) + assert user + + user_id = user.id + + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + channels = [ + channel.broadcaster_login + async for channel in await client.get_followed_channels(user_id) + ] + + return self.async_create_entry( + title=user.display_name, data=data, options={CONF_CHANNELS: channels} + ) + + if self.reauth_entry.unique_id == user_id: + new_channels = self.reauth_entry.options[CONF_CHANNELS] + # Since we could not get all channels at import, we do it at the reauth + # immediately after. + if "imported" in self.reauth_entry.data: + channels = [ + channel.broadcaster_login + async for channel in await client.get_followed_channels(user_id) + ] + options = list(set(channels) - set(new_channels)) + new_channels = [*new_channels, *options] + + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data=data, + options={CONF_CHANNELS: new_channels}, + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_abort( + reason="wrong_account", + description_placeholders={"title": self.reauth_entry.title}, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import from yaml.""" + client = await Twitch( + app_id=config[CONF_CLIENT_ID], + authenticate_app=False, + ) + client.auto_refresh_auth = False + token = config[CONF_TOKEN] + try: + await client.set_user_authentication( + token, validate=True, scope=[AuthScope.USER_READ_SUBSCRIPTIONS] + ) + except InvalidTokenException: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_invalid_token", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_invalid_token", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + return self.async_abort(reason="invalid_token") + user = await first(client.get_users()) + assert user + await self.async_set_unique_id(user.id) + try: + self._abort_if_unique_id_configured() + except AbortFlow as err: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_already_imported", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_already_imported", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + raise err + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) + return self.async_create_entry( + title=user.display_name, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + CONF_ACCESS_TOKEN: token, + CONF_REFRESH_TOKEN: "", + "expires_at": 0, + }, + "imported": True, + }, + options={CONF_CHANNELS: config[CONF_CHANNELS]}, + ) diff --git a/homeassistant/components/twitch/const.py b/homeassistant/components/twitch/const.py index 6626889a809..22286437eab 100644 --- a/homeassistant/components/twitch/const.py +++ b/homeassistant/components/twitch/const.py @@ -3,8 +3,18 @@ import logging from twitchAPI.twitch import AuthScope +from homeassistant.const import Platform + LOGGER = logging.getLogger(__package__) +PLATFORMS = [Platform.SENSOR] + +OAUTH2_AUTHORIZE = "https://id.twitch.tv/oauth2/authorize" +OAUTH2_TOKEN = "https://id.twitch.tv/oauth2/token" + +CONF_REFRESH_TOKEN = "refresh_token" + +DOMAIN = "twitch" CONF_CHANNELS = "channels" -OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS] +OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS, AuthScope.USER_READ_FOLLOWS] diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json index 5613360c594..810982d0cb4 100644 --- a/homeassistant/components/twitch/manifest.json +++ b/homeassistant/components/twitch/manifest.json @@ -2,8 +2,10 @@ "domain": "twitch", "name": "Twitch", "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/twitch", "iot_class": "cloud_polling", "loggers": ["twitch"], - "requirements": ["twitchAPI==3.10.0"] + "requirements": ["twitchAPI==4.0.0"] } diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 3211ca1952b..11d6611ef99 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -4,24 +4,27 @@ from __future__ import annotations from twitchAPI.helper import first from twitchAPI.twitch import ( AuthType, - InvalidTokenException, - MissingScopeException, Twitch, TwitchAPIException, - TwitchAuthorizationException, TwitchResourceNotFound, TwitchUser, ) import voluptuous as vol +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_CHANNELS, LOGGER, OAUTH_SCOPES +from .const import CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -56,40 +59,46 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Twitch platform.""" - channels = config[CONF_CHANNELS] - client_id = config[CONF_CLIENT_ID] - client_secret = config[CONF_CLIENT_SECRET] - oauth_token = config.get(CONF_TOKEN) - - try: - client = await Twitch( - app_id=client_id, - app_secret=client_secret, - target_app_auth_scope=OAUTH_SCOPES, - ) - client.auto_refresh_auth = False - except TwitchAuthorizationException: - LOGGER.error("Invalid client ID or client secret") - return - - if oauth_token: - try: - await client.set_user_authentication( - token=oauth_token, scope=OAUTH_SCOPES, validate=True + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET]), + ) + if CONF_TOKEN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) - except MissingScopeException: - LOGGER.error("OAuth token is missing required scope") - return - except InvalidTokenException: - LOGGER.error("OAuth token is invalid") - return + ) + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_credentials_imported", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_credentials_imported", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Twitch", + }, + ) - twitch_users: list[TwitchUser] = [] - async for channel in client.get_users(logins=channels): - twitch_users.append(channel) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize entries.""" + client = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [TwitchSensor(channel, client) for channel in twitch_users], + [ + TwitchSensor(channel, client) + async for channel in client.get_users(logins=entry.options[CONF_CHANNELS]) + ], True, ) @@ -109,7 +118,7 @@ class TwitchSensor(SensorEntity): async def async_update(self) -> None: """Update device state.""" - followers = (await self._client.get_users_follows(to_id=self._channel.id)).total + followers = (await self._client.get_channel_followers(self._channel.id)).total self._attr_extra_state_attributes = { ATTR_FOLLOWING: followers, ATTR_VIEWS: self._channel.view_count, @@ -149,13 +158,11 @@ class TwitchSensor(SensorEntity): except TwitchAPIException as exc: LOGGER.error("Error response on check_user_subscription: %s", exc) - follows = ( - await self._client.get_users_follows( - from_id=user.id, to_id=self._channel.id - ) - ).data - self._attr_extra_state_attributes[ATTR_FOLLOW] = len(follows) > 0 - if len(follows): - self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows[ + follows = await self._client.get_followed_channels( + user.id, broadcaster_id=self._channel.id + ) + self._attr_extra_state_attributes[ATTR_FOLLOW] = follows.total > 0 + if follows.total: + self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows.data[ 0 ].followed_at diff --git a/homeassistant/components/twitch/strings.json b/homeassistant/components/twitch/strings.json new file mode 100644 index 00000000000..45f88747128 --- /dev/null +++ b/homeassistant/components/twitch/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Twitch integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with {username}." + } + }, + "issues": { + "deprecated_yaml_invalid_token": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour configuration couldn't be imported because the token in the configuration.yaml was invalid.\n\nPlease add Twitch again via the UI.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_credentials_imported": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour application credentials are imported, but a config entry could not be created because there was no access token.\n\nPlease add Twitch again via the UI.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_already_imported": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour application credentials are already imported.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 78c98bcc03d..8c9e3a57ddc 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -18,6 +18,7 @@ APPLICATION_CREDENTIALS = [ "netatmo", "senz", "spotify", + "twitch", "withings", "xbox", "yolink", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c3ee346664a..552e7cf991c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -500,6 +500,7 @@ FLOWS = { "twentemilieu", "twilio", "twinkly", + "twitch", "ukraine_alarm", "unifi", "unifiprotect", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bc759ec1ae6..8215554784f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6021,7 +6021,7 @@ "twitch": { "name": "Twitch", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "twitter": { diff --git a/requirements_all.txt b/requirements_all.txt index edd19f6b96f..b1cb32b5eec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2613,7 +2613,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==3.10.0 +twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 125582078e9..4858e1ec004 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1934,7 +1934,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==3.10.0 +twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index bf35484f53e..26746c7abb4 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -1,10 +1,10 @@ """Tests for the Twitch component.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, AsyncIterator from dataclasses import dataclass -from typing import Any +from datetime import datetime -from twitchAPI.object import TwitchUser +from twitchAPI.object.api import FollowedChannelsResult, TwitchUser from twitchAPI.twitch import ( InvalidTokenException, MissingScopeException, @@ -12,24 +12,34 @@ from twitchAPI.twitch import ( TwitchAuthorizationException, TwitchResourceNotFound, ) -from twitchAPI.types import AuthScope, AuthType +from twitchAPI.type import AuthScope, AuthType -USER_OBJECT: TwitchUser = TwitchUser( - id=123, - display_name="channel123", - offline_image_url="logo.png", - profile_image_url="logo.png", - view_count=42, -) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry -class TwitchUserFollowResultMock: - """Mock for twitch user follow result.""" +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) - def __init__(self, follows: list[dict[str, Any]]) -> None: - """Initialize mock.""" - self.total = len(follows) - self.data = follows + await hass.config_entries.async_setup(config_entry.entry_id) + + +def _get_twitch_user(user_id: str = "123") -> TwitchUser: + return TwitchUser( + id=user_id, + display_name="channel123", + offline_image_url="logo.png", + profile_image_url="logo.png", + view_count=42, + ) + + +async def async_iterator(iterable) -> AsyncIterator: + """Return async iterator.""" + for i in iterable: + yield i @dataclass @@ -41,12 +51,20 @@ class UserSubscriptionMock: @dataclass -class UserFollowMock: - """User follow mock.""" +class FollowedChannelMock: + """Followed channel mock.""" + broadcaster_login: str followed_at: str +@dataclass +class ChannelFollowerMock: + """Channel follower mock.""" + + user_id: str + + @dataclass class StreamMock: """Stream mock.""" @@ -56,6 +74,32 @@ class StreamMock: thumbnail_url: str +class TwitchUserFollowResultMock: + """Mock for twitch user follow result.""" + + def __init__(self, follows: list[FollowedChannelMock]) -> None: + """Initialize mock.""" + self.total = len(follows) + self.data = follows + + def __aiter__(self): + """Return async iterator.""" + return async_iterator(self.data) + + +class ChannelFollowersResultMock: + """Mock for twitch channel follow result.""" + + def __init__(self, follows: list[ChannelFollowerMock]) -> None: + """Initialize mock.""" + self.total = len(follows) + self.data = follows + + def __aiter__(self): + """Return async iterator.""" + return async_iterator(self.data) + + STREAMS = StreamMock( game_name="Good game", title="Title", thumbnail_url="stream-medium.png" ) @@ -64,25 +108,18 @@ STREAMS = StreamMock( class TwitchMock: """Mock for the twitch object.""" + is_streaming = True + is_gifted = False + is_subscribed = False + is_following = True + different_user_id = False + def __await__(self): """Add async capabilities to the mock.""" t = asyncio.create_task(self._noop()) yield from t return self - def __init__( - self, - is_streaming: bool = True, - is_gifted: bool = False, - is_subscribed: bool = False, - is_following: bool = True, - ) -> None: - """Initialize mock.""" - self._is_streaming = is_streaming - self._is_gifted = is_gifted - self._is_subscribed = is_subscribed - self._is_following = is_following - async def _noop(self): """Fake function to create task.""" pass @@ -91,7 +128,8 @@ class TwitchMock: self, user_ids: list[str] | None = None, logins: list[str] | None = None ) -> AsyncGenerator[TwitchUser, None]: """Get list of mock users.""" - for user in [USER_OBJECT]: + users = [_get_twitch_user("234" if self.different_user_id else "123")] + for user in users: yield user def has_required_auth( @@ -100,38 +138,56 @@ class TwitchMock: """Return if auth required.""" return True - async def get_users_follows( - self, to_id: str | None = None, from_id: str | None = None - ) -> TwitchUserFollowResultMock: - """Return the followers of the user.""" - if self._is_following: - return TwitchUserFollowResultMock( - follows=[UserFollowMock("2020-01-20T21:22:42") for _ in range(0, 24)] - ) - return TwitchUserFollowResultMock(follows=[]) - async def check_user_subscription( self, broadcaster_id: str, user_id: str ) -> UserSubscriptionMock: """Check if the user is subscribed.""" - if self._is_subscribed: + if self.is_subscribed: return UserSubscriptionMock( - broadcaster_id=broadcaster_id, is_gift=self._is_gifted + broadcaster_id=broadcaster_id, is_gift=self.is_gifted ) raise TwitchResourceNotFound async def set_user_authentication( - self, token: str, scope: list[AuthScope], validate: bool = True + self, + token: str, + scope: list[AuthScope], + validate: bool = True, ) -> None: """Set user authentication.""" pass + async def get_followed_channels( + self, user_id: str, broadcaster_id: str | None = None + ) -> FollowedChannelsResult: + """Get followed channels.""" + if self.is_following: + return TwitchUserFollowResultMock( + [ + FollowedChannelMock( + followed_at=datetime(year=2023, month=8, day=1), + broadcaster_login="internetofthings", + ), + FollowedChannelMock( + followed_at=datetime(year=2023, month=8, day=1), + broadcaster_login="homeassistant", + ), + ] + ) + return TwitchUserFollowResultMock([]) + + async def get_channel_followers( + self, broadcaster_id: str + ) -> ChannelFollowersResultMock: + """Get channel followers.""" + return ChannelFollowersResultMock([ChannelFollowerMock(user_id="abc")]) + async def get_streams( self, user_id: list[str], first: int ) -> AsyncGenerator[StreamMock, None]: """Get streams for the user.""" streams = [] - if self._is_streaming: + if self.is_streaming: streams = [STREAMS] for stream in streams: yield stream diff --git a/tests/components/twitch/conftest.py b/tests/components/twitch/conftest.py new file mode 100644 index 00000000000..b3894203786 --- /dev/null +++ b/tests/components/twitch/conftest.py @@ -0,0 +1,110 @@ +"""Configure tests for the Twitch integration.""" +from collections.abc import Awaitable, Callable, Generator +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.twitch import TwitchMock +from tests.test_util.aiohttp import AiohttpClientMocker + +ComponentSetup = Callable[[TwitchMock | None], Awaitable[None]] + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +TITLE = "Test" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.twitch.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return [scope.value for scope in OAUTH_SCOPES] + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Twitch entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="123", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + options={"channels": ["internetofthings"]}, + ) + + +@pytest.fixture(autouse=True) +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Twitch connection.""" + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.fixture(name="twitch_mock") +def twitch_mock() -> TwitchMock: + """Return as fixture to inject other mocks.""" + return TwitchMock() + + +@pytest.fixture(name="twitch") +def mock_twitch(twitch_mock: TwitchMock): + """Mock Twitch.""" + with patch( + "homeassistant.components.twitch.Twitch", + return_value=twitch_mock, + ), patch( + "homeassistant.components.twitch.config_flow.Twitch", + return_value=twitch_mock, + ): + yield twitch_mock diff --git a/tests/components/twitch/test_config_flow.py b/tests/components/twitch/test_config_flow.py new file mode 100644 index 00000000000..36312fea83e --- /dev/null +++ b/tests/components/twitch/test_config_flow.py @@ -0,0 +1,295 @@ +"""Test config flow for Twitch.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.twitch.const import ( + CONF_CHANNELS, + DOMAIN, + OAUTH2_AUTHORIZE, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.twitch import TwitchInvalidTokenMock, TwitchMock +from tests.components.twitch.conftest import CLIENT_ID, TITLE +from tests.typing import ClientSessionGenerator + + +async def _do_get_token( + hass: HomeAssistant, + result: FlowResult, + hass_client_no_auth: ClientSessionGenerator, + scopes: list[str], +) -> None: + 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={'+'.join(scopes)}" + ) + + 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" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "twitch", context={"source": SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "channel123" + assert "result" in result + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].unique_id == "123" + assert result["options"] == {CONF_CHANNELS: ["internetofthings", "homeassistant"]} + + +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check flow aborts when account already configured.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + "twitch", context={"source": SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + with patch( + "homeassistant.components.twitch.config_flow.Twitch", return_value=TwitchMock() + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check reauth flow.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_from_import( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, + expires_at, + scopes: list[str], +) -> None: + """Check reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="123", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + "imported": True, + }, + options={"channels": ["internetofthings"]}, + ) + await test_reauth( + hass, + hass_client_no_auth, + current_request_with_host, + config_entry, + mock_setup_entry, + twitch, + scopes, + ) + entries = hass.config_entries.async_entries(DOMAIN) + entry = entries[0] + assert "imported" not in entry.data + assert entry.options == {CONF_CHANNELS: ["internetofthings", "homeassistant"]} + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, + scopes: list[str], +) -> None: + """Check reauth flow.""" + await setup_integration(hass, config_entry) + twitch.different_user_id = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + await _do_get_token(hass, result, hass_client_no_auth, scopes) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_import( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "channel123" + assert "result" in result + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "efgh" + assert result["result"].data["token"]["refresh_token"] == "" + assert result["result"].unique_id == "123" + assert result["options"] == {CONF_CHANNELS: ["channel123"]} + + +@pytest.mark.parametrize("twitch_mock", [TwitchInvalidTokenMock()]) +async def test_import_invalid_token( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_token" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_import_already_imported( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + config_entry: MockConfigEntry, + mock_setup_entry, + twitch: TwitchMock, +) -> None: + """Test import flow where the config is already imported.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_IMPORT, + }, + data={ + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + }, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/twitch/test_init.py b/tests/components/twitch/test_init.py new file mode 100644 index 00000000000..da03857a95d --- /dev/null +++ b/tests/components/twitch/test_init.py @@ -0,0 +1,116 @@ +"""Tests for YouTube.""" +import http +import time +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import TwitchMock, setup_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_success( + hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock +) -> None: + """Test successful setup and unload.""" + await setup_integration(hass, config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not hass.services.async_services().get(DOMAIN) + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + twitch: TwitchMock, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration(hass, config_entry) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, + config_entry: MockConfigEntry, + twitch: TwitchMock, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await setup_integration(hass, config_entry) + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_expired_token_refresh_client_error( + hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock +) -> None: + """Test failure while refreshing token with a client error.""" + + with patch( + "homeassistant.components.twitch.OAuth2Session.async_ensure_token_valid", + side_effect=ClientError, + ): + await setup_integration(hass, config_entry) + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/twitch/test_sensor.py b/tests/components/twitch/test_sensor.py new file mode 100644 index 00000000000..047c55d3b72 --- /dev/null +++ b/tests/components/twitch/test_sensor.py @@ -0,0 +1,177 @@ +"""The tests for an update of the Twitch component.""" +from datetime import datetime + +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.twitch.const import CONF_CHANNELS, DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from ...common import MockConfigEntry +from . import ( + TwitchAPIExceptionMock, + TwitchInvalidTokenMock, + TwitchInvalidUserMock, + TwitchMissingScopeMock, + TwitchMock, + TwitchUnauthorizedMock, + setup_integration, +) + +ENTITY_ID = "sensor.channel123" +CONFIG = { + "auth_implementation": "cred", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", +} + +LEGACY_CONFIG_WITHOUT_TOKEN = { + SENSOR_DOMAIN: { + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + "channels": ["channel123"], + } +} + +LEGACY_CONFIG = { + SENSOR_DOMAIN: { + "platform": "twitch", + CONF_CLIENT_ID: "1234", + CONF_CLIENT_SECRET: "abcd", + CONF_TOKEN: "efgh", + "channels": ["channel123"], + } +} + +OPTIONS = {CONF_CHANNELS: ["channel123"]} + + +async def test_legacy_migration( + hass: HomeAssistant, twitch: TwitchMock, mock_setup_entry +) -> None: + """Test importing legacy yaml.""" + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_legacy_migration_without_token( + hass: HomeAssistant, twitch: TwitchMock +) -> None: + """Test importing legacy yaml.""" + assert await async_setup_component( + hass, Platform.SENSOR, LEGACY_CONFIG_WITHOUT_TOKEN + ) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_offline( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test offline state.""" + twitch.is_streaming = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "offline" + assert sensor_state.attributes["entity_picture"] == "logo.png" + + +async def test_streaming( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test streaming state.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "streaming" + assert sensor_state.attributes["entity_picture"] == "stream-medium.png" + assert sensor_state.attributes["game"] == "Good game" + assert sensor_state.attributes["title"] == "Title" + + +async def test_oauth_without_sub_and_follow( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth.""" + twitch.is_following = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_sub( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth and sub.""" + twitch.is_subscribed = True + twitch.is_following = False + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is True + assert sensor_state.attributes["subscription_is_gifted"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_follow( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test state with oauth and follow.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["following"] is True + assert sensor_state.attributes["following_since"] == datetime( + year=2023, month=8, day=1 + ) + + +@pytest.mark.parametrize( + "twitch_mock", + [TwitchUnauthorizedMock(), TwitchMissingScopeMock(), TwitchInvalidTokenMock()], +) +async def test_auth_invalid( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth failures.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state is None + + +@pytest.mark.parametrize("twitch_mock", [TwitchInvalidUserMock()]) +async def test_auth_with_invalid_user( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth with invalid user.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert "subscribed" not in sensor_state.attributes + + +@pytest.mark.parametrize("twitch_mock", [TwitchAPIExceptionMock()]) +async def test_auth_with_api_exception( + hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry +) -> None: + """Test auth with invalid user.""" + await setup_integration(hass, config_entry) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert "subscription_is_gifted" not in sensor_state.attributes diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py deleted file mode 100644 index 4a33831dd32..00000000000 --- a/tests/components/twitch/test_twitch.py +++ /dev/null @@ -1,205 +0,0 @@ -"""The tests for an update of the Twitch component.""" -from unittest.mock import patch - -from homeassistant.components import sensor -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from . import ( - TwitchAPIExceptionMock, - TwitchInvalidTokenMock, - TwitchInvalidUserMock, - TwitchMissingScopeMock, - TwitchMock, - TwitchUnauthorizedMock, -) - -ENTITY_ID = "sensor.channel123" -CONFIG = { - sensor.DOMAIN: { - "platform": "twitch", - CONF_CLIENT_ID: "1234", - CONF_CLIENT_SECRET: " abcd", - "channels": ["channel123"], - } -} -CONFIG_WITH_OAUTH = { - sensor.DOMAIN: { - "platform": "twitch", - CONF_CLIENT_ID: "1234", - CONF_CLIENT_SECRET: "abcd", - "channels": ["channel123"], - "token": "9876", - } -} - - -async def test_init(hass: HomeAssistant) -> None: - """Test initial config.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_streaming=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "offline" - assert sensor_state.name == "channel123" - assert sensor_state.attributes["icon"] == "mdi:twitch" - assert sensor_state.attributes["friendly_name"] == "channel123" - assert sensor_state.attributes["views"] == 42 - assert sensor_state.attributes["followers"] == 24 - - -async def test_offline(hass: HomeAssistant) -> None: - """Test offline state.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_streaming=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "offline" - assert sensor_state.attributes["entity_picture"] == "logo.png" - - -async def test_streaming(hass: HomeAssistant) -> None: - """Test streaming state.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.state == "streaming" - assert sensor_state.attributes["entity_picture"] == "stream-medium.png" - assert sensor_state.attributes["game"] == "Good game" - assert sensor_state.attributes["title"] == "Title" - - -async def test_oauth_without_sub_and_follow(hass: HomeAssistant) -> None: - """Test state with oauth.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(is_following=False), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is False - assert sensor_state.attributes["following"] is False - - -async def test_oauth_with_sub(hass: HomeAssistant) -> None: - """Test state with oauth and sub.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock( - is_subscribed=True, is_gifted=False, is_following=False - ), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is True - assert sensor_state.attributes["subscription_is_gifted"] is False - assert sensor_state.attributes["following"] is False - - -async def test_oauth_with_follow(hass: HomeAssistant) -> None: - """Test state with oauth and follow.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["following"] is True - assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42" - - -async def test_auth_with_invalid_credentials(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchUnauthorizedMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_missing_scope(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchMissingScopeMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_invalid_token(hass: HomeAssistant) -> None: - """Test auth with invalid credentials.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchInvalidTokenMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -async def test_auth_with_invalid_user(hass: HomeAssistant) -> None: - """Test auth with invalid user.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchInvalidUserMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert "subscribed" not in sensor_state.attributes - - -async def test_auth_with_api_exception(hass: HomeAssistant) -> None: - """Test auth with invalid user.""" - - with patch( - "homeassistant.components.twitch.sensor.Twitch", - return_value=TwitchAPIExceptionMock(), - ): - assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) - await hass.async_block_till_done() - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is False - assert "subscription_is_gifted" not in sensor_state.attributes From 8b5bfd8ceec2e66b26663121a3e40f69ea7c5474 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 27 Sep 2023 17:08:51 +0200 Subject: [PATCH 882/984] Add test helper for cloud status updates (#100993) * Add helper for cloud status updates * Move import --- tests/common.py | 19 ++++++++++++++++++- tests/components/withings/test_init.py | 16 ++++------------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/tests/common.py b/tests/common.py index af18640843d..cd522aa3320 100644 --- a/tests/common.py +++ b/tests/common.py @@ -67,7 +67,10 @@ from homeassistant.helpers import ( restore_state as rs, storage, ) -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component @@ -1443,3 +1446,17 @@ def async_get_persistent_notifications( ) -> dict[str, pn.Notification]: """Get the current persistent notifications.""" return pn._async_get_or_create_notifications(hass) + + +def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> None: + """Mock a signal the cloud disconnected.""" + from homeassistant.components.cloud import ( + SIGNAL_CLOUD_CONNECTION_STATE, + CloudConnectionState, + ) + + if connected: + state = CloudConnectionState.CLOUD_CONNECTED + else: + state = CloudConnectionState.CLOUD_DISCONNECTED + async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 353dcee8a7c..a3918a6ff19 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -11,11 +11,7 @@ from withings_api import NotifyListResponse from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException from homeassistant import config_entries -from homeassistant.components.cloud import ( - SIGNAL_CLOUD_CONNECTION_STATE, - CloudConnectionState, - CloudNotAvailable, -) +from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, async_setup from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN @@ -26,7 +22,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt as dt_util from . import call_webhook, setup_integration @@ -35,6 +30,7 @@ from .conftest import USER_ID, WEBHOOK_ID from tests.common import ( MockConfigEntry, async_fire_time_changed, + async_mock_cloud_connection_status, load_json_object_fixture, ) from tests.components.cloud import mock_cloud @@ -506,16 +502,12 @@ async def test_cloud_disconnect( assert withings.async_notify_subscribe.call_count == 6 - async_dispatcher_send( - hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_DISCONNECTED - ) + async_mock_cloud_connection_status(hass, False) await hass.async_block_till_done() assert withings.async_notify_revoke.call_count == 3 - async_dispatcher_send( - hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED - ) + async_mock_cloud_connection_status(hass, True) await hass.async_block_till_done() assert withings.async_notify_subscribe.call_count == 12 From 9fdc8494b698196306b3d3d199d09ed368b31718 Mon Sep 17 00:00:00 2001 From: nachonam Date: Wed, 27 Sep 2023 17:11:31 +0200 Subject: [PATCH 883/984] Add Freebox Home binary sensors (#92196) Co-authored-by: Quentame --- .../components/freebox/binary_sensor.py | 92 ++++++++++++++++++- homeassistant/components/freebox/camera.py | 12 +-- homeassistant/components/freebox/const.py | 2 + homeassistant/components/freebox/home_base.py | 27 ++++-- homeassistant/components/freebox/router.py | 3 +- tests/components/freebox/conftest.py | 24 ++++- tests/components/freebox/const.py | 60 +++++++++++- .../components/freebox/test_binary_sensor.py | 52 ++++++++++- tests/components/freebox/test_sensor.py | 4 +- 9 files changed, 249 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 10a151dbcf6..b5e0258d844 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -15,11 +15,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, FreeboxHomeCategory +from .home_base import FreeboxHomeEntity from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) + RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="raid_degraded", @@ -33,21 +35,105 @@ RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the binary sensors.""" + """Set up binary sensors.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] _LOGGER.debug("%s - %s - %s raid(s)", router.name, router.mac, len(router.raids)) - binary_entities = [ + binary_entities: list[BinarySensorEntity] = [ FreeboxRaidDegradedSensor(router, raid, description) for raid in router.raids.values() for description in RAID_SENSORS ] + for node in router.home_devices.values(): + if node["category"] == FreeboxHomeCategory.PIR: + binary_entities.append(FreeboxPirSensor(hass, router, node)) + elif node["category"] == FreeboxHomeCategory.DWS: + binary_entities.append(FreeboxDwsSensor(hass, router, node)) + + for endpoint in node["show_endpoints"]: + if ( + endpoint["name"] == "cover" + and endpoint["ep_type"] == "signal" + and endpoint.get("value") is not None + ): + binary_entities.append(FreeboxCoverSensor(hass, router, node)) + if binary_entities: async_add_entities(binary_entities, True) +class FreeboxHomeBinarySensor(FreeboxHomeEntity, BinarySensorEntity): + """Representation of a Freebox binary sensor.""" + + _sensor_name = "trigger" + + def __init__( + self, + hass: HomeAssistant, + router: FreeboxRouter, + node: dict[str, Any], + sub_node: dict[str, Any] | None = None, + ) -> None: + """Initialize a Freebox binary sensor.""" + super().__init__(hass, router, node, sub_node) + self._command_id = self.get_command_id( + node["type"]["endpoints"], "signal", self._sensor_name + ) + self._attr_is_on = self._edit_state(self.get_value("signal", self._sensor_name)) + + async def async_update_signal(self): + """Update name & state.""" + self._attr_is_on = self._edit_state( + await self.get_home_endpoint_value(self._command_id) + ) + await FreeboxHomeEntity.async_update_signal(self) + + def _edit_state(self, state: bool | None) -> bool | None: + """Edit state depending on sensor name.""" + if state is None: + return None + if self._sensor_name == "trigger": + return not state + return state + + +class FreeboxPirSensor(FreeboxHomeBinarySensor): + """Representation of a Freebox motion binary sensor.""" + + _attr_device_class = BinarySensorDeviceClass.MOTION + + +class FreeboxDwsSensor(FreeboxHomeBinarySensor): + """Representation of a Freebox door opener binary sensor.""" + + _attr_device_class = BinarySensorDeviceClass.DOOR + + +class FreeboxCoverSensor(FreeboxHomeBinarySensor): + """Representation of a cover Freebox plastic removal cover binary sensor (for some sensors: motion detector, door opener detector...).""" + + _attr_device_class = BinarySensorDeviceClass.SAFETY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + _sensor_name = "cover" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize a cover for another device.""" + cover_node = next( + filter( + lambda x: (x["name"] == self._sensor_name and x["ep_type"] == "signal"), + node["type"]["endpoints"], + ), + None, + ) + super().__init__(hass, router, node, cover_node) + + class FreeboxRaidDegradedSensor(BinarySensorEntity): """Representation of a Freebox raid sensor.""" diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index fd11b949890..f5c86ec0bce 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -80,27 +80,27 @@ class FreeboxCamera(FreeboxHomeEntity, FFmpegCamera): ) self._command_motion_detection = self.get_command_id( - node["type"]["endpoints"], ATTR_DETECTION + node["type"]["endpoints"], "slot", ATTR_DETECTION ) self._attr_extra_state_attributes = {} self.update_node(node) async def async_enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" - await self.set_home_endpoint_value(self._command_motion_detection, True) - self._attr_motion_detection_enabled = True + if await self.set_home_endpoint_value(self._command_motion_detection, True): + self._attr_motion_detection_enabled = True async def async_disable_motion_detection(self) -> None: """Disable motion detection in camera.""" - await self.set_home_endpoint_value(self._command_motion_detection, False) - self._attr_motion_detection_enabled = False + if await self.set_home_endpoint_value(self._command_motion_detection, False): + self._attr_motion_detection_enabled = False async def async_update_signal(self) -> None: """Update the camera node.""" self.update_node(self._router.home_devices[self._id]) self.async_write_ha_state() - def update_node(self, node): + def update_node(self, node: dict[str, Any]) -> None: """Update params.""" self._name = node["label"].strip() diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 5bed7b3456a..0c3450d13b6 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -86,6 +86,8 @@ CATEGORY_TO_MODEL = { HOME_COMPATIBLE_CATEGORIES = [ FreeboxHomeCategory.CAMERA, FreeboxHomeCategory.DWS, + FreeboxHomeCategory.IOHOME, FreeboxHomeCategory.KFB, FreeboxHomeCategory.PIR, + FreeboxHomeCategory.RTS, ] diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index d0bb8b10309..2cc1a5fcfe3 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -77,23 +77,36 @@ class FreeboxHomeEntity(Entity): ) self.async_write_ha_state() - async def set_home_endpoint_value(self, command_id: Any, value=None) -> None: + async def set_home_endpoint_value(self, command_id: Any, value=None) -> bool: """Set Home endpoint value.""" if command_id is None: _LOGGER.error("Unable to SET a value through the API. Command is None") - return + return False + await self._router.home.set_home_endpoint_value( self._id, command_id, {"value": value} ) + return True - def get_command_id(self, nodes, name) -> int | None: + async def get_home_endpoint_value(self, command_id: Any) -> Any | None: + """Get Home endpoint value.""" + if command_id is None: + _LOGGER.error("Unable to GET a value through the API. Command is None") + return None + + node = await self._router.home.get_home_endpoint_value(self._id, command_id) + return node.get("value") + + def get_command_id(self, nodes, ep_type, name) -> int | None: """Get the command id.""" node = next( - filter(lambda x: (x["name"] == name), nodes), + filter(lambda x: (x["name"] == name and x["ep_type"] == ep_type), nodes), None, ) if not node: - _LOGGER.warning("The Freebox Home device has no value for: %s", name) + _LOGGER.warning( + "The Freebox Home device has no command value for: %s/%s", name, ep_type + ) return None return node["id"] @@ -115,7 +128,7 @@ class FreeboxHomeEntity(Entity): """Register state update callback.""" self._remove_signal_update = dispacher - def get_value(self, ep_type, name): + def get_value(self, ep_type: str, name: str): """Get the value.""" node = next( filter( @@ -126,7 +139,7 @@ class FreeboxHomeEntity(Entity): ) if not node: _LOGGER.warning( - "The Freebox Home device has no node for: %s/%s", ep_type, name + "The Freebox Home device has no node value for: %s/%s", ep_type, name ) return None return node.get("value") diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index cd5862a2f80..6a73624a776 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -118,6 +118,7 @@ class FreeboxRouter: async def update_sensors(self) -> None: """Update Freebox sensors.""" + # System sensors syst_datas: dict[str, Any] = await self._api.system.get_config() @@ -145,7 +146,6 @@ class FreeboxRouter: self.call_list = await self._api.call.get_calls_log() await self._update_disks_sensors() - await self._update_raids_sensors() async_dispatcher_send(self.hass, self.signal_sensor_update) @@ -165,6 +165,7 @@ class FreeboxRouter: async def _update_raids_sensors(self) -> None: """Update Freebox raids.""" + # None at first request if not self.supports_raid: return diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 69b250412bd..63bc1d76d1a 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -1,5 +1,5 @@ """Test helpers for Freebox.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import pytest @@ -10,6 +10,7 @@ from .const import ( DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, + DATA_HOME_GET_VALUES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, @@ -27,6 +28,16 @@ def mock_path(): yield +@pytest.fixture(autouse=True) +def enable_all_entities(): + """Make sure all entities are enabled.""" + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + PropertyMock(return_value=True), + ): + yield + + @pytest.fixture def mock_device_registry_devices(hass: HomeAssistant, device_registry): """Create device registry devices so the device tracker entities are enabled.""" @@ -56,18 +67,21 @@ def mock_router(mock_device_registry_devices): instance = service_mock.return_value instance.open = AsyncMock() instance.system.get_config = AsyncMock(return_value=DATA_SYSTEM_GET_CONFIG) + # device_tracker + instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) instance.storage.get_raids = AsyncMock(return_value=DATA_STORAGE_GET_RAIDS) - # home devices - instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.connection.get_status = AsyncMock( return_value=DATA_CONNECTION_GET_STATUS ) # switch instance.wifi.get_global_config = AsyncMock(return_value=WIFI_GET_GLOBAL_CONFIG) - # device_tracker - instance.lan.get_hosts_list = AsyncMock(return_value=DATA_LAN_GET_HOSTS_LIST) + # home devices + instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) + instance.home.get_home_endpoint_value = AsyncMock( + return_value=DATA_HOME_GET_VALUES + ) instance.close = AsyncMock() yield service_mock diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 0b58348a5df..788310bdbc0 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -513,7 +513,22 @@ DATA_LAN_GET_HOSTS_LIST = [ }, ] +# Home +# PIR node id 26, endpoint id 6 +DATA_HOME_GET_VALUES = { + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", +} +# Home +# ALL DATA_HOME_GET_NODES = [ { "adapter": 2, @@ -2110,6 +2125,22 @@ DATA_HOME_GET_NODES = [ "value_type": "bool", "visibility": "normal", }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png", + }, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, { "category": "", "ep_type": "signal", @@ -2211,7 +2242,7 @@ DATA_HOME_GET_NODES = [ "ep_type": "signal", "id": 7, "label": "Couvercle", - "name": "1cover", + "name": "cover", "param_type": "void", "value_type": "bool", "visibility": "normal", @@ -2302,6 +2333,33 @@ DATA_HOME_GET_NODES = [ "value_type": "bool", "visibility": "normal", }, + { + "category": "", + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "refresh": 2000, + "ui": { + "access": "r", + "display": "warning", + "icon_url": "/resources/images/home/pictos/warning.png", + }, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, { "category": "", "ep_type": "signal", diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index 218ef953ee0..b37d6a3c72c 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -4,12 +4,16 @@ from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_STORAGE_GET_RAIDS +from .const import DATA_HOME_GET_VALUES, DATA_STORAGE_GET_RAIDS from tests.common import async_fire_time_changed @@ -38,3 +42,47 @@ async def test_raid_array_degraded( hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state == "on" ) + + +async def test_home( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test home binary sensors.""" + await setup_platform(hass, BINARY_SENSOR_DOMAIN) + + # Device class + assert ( + hass.states.get("binary_sensor.detecteur").attributes[ATTR_DEVICE_CLASS] + == BinarySensorDeviceClass.MOTION + ) + assert ( + hass.states.get("binary_sensor.ouverture_porte").attributes[ATTR_DEVICE_CLASS] + == BinarySensorDeviceClass.DOOR + ) + assert ( + hass.states.get("binary_sensor.ouverture_porte_couvercle").attributes[ + ATTR_DEVICE_CLASS + ] + == BinarySensorDeviceClass.SAFETY + ) + + # Initial state + assert hass.states.get("binary_sensor.detecteur").state == "on" + assert hass.states.get("binary_sensor.detecteur_couvercle").state == "off" + assert hass.states.get("binary_sensor.ouverture_porte").state == "unknown" + assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off" + + # Now simulate a changed status + data_home_get_values_changed = deepcopy(DATA_HOME_GET_VALUES) + data_home_get_values_changed["value"] = True + router().home.get_home_endpoint_value.return_value = data_home_get_values_changed + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.detecteur").state == "off" + assert hass.states.get("binary_sensor.detecteur_couvercle").state == "on" + assert hass.states.get("binary_sensor.ouverture_porte").state == "off" + assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "on" diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py index 801e8508d86..0abdc55b92c 100644 --- a/tests/components/freebox/test_sensor.py +++ b/tests/components/freebox/test_sensor.py @@ -104,8 +104,8 @@ async def test_battery( # Simulate a changed battery data_home_get_nodes_changed = deepcopy(DATA_HOME_GET_NODES) data_home_get_nodes_changed[2]["show_endpoints"][3]["value"] = 25 - data_home_get_nodes_changed[3]["show_endpoints"][3]["value"] = 50 - data_home_get_nodes_changed[4]["show_endpoints"][3]["value"] = 75 + data_home_get_nodes_changed[3]["show_endpoints"][4]["value"] = 50 + data_home_get_nodes_changed[4]["show_endpoints"][5]["value"] = 75 router().home.get_home_nodes.return_value = data_home_get_nodes_changed # Simulate an update freezer.tick(SCAN_INTERVAL) From c59404b5bc78e5e3c4f9c443d1106772a606379c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 27 Sep 2023 17:19:20 +0200 Subject: [PATCH 884/984] Fix additional test cases for Python 3.12 (#101006) --- tests/components/hassio/test_handler.py | 2 +- tests/helpers/test_script.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 5a89ea8335a..d92a5335809 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -320,8 +320,8 @@ async def test_api_ingress_panels( ], ) async def test_api_headers( + aiohttp_raw_server, # 'aiohttp_raw_server' must be before 'hass'! hass, - aiohttp_raw_server, socket_enabled, api_call: str, method: Literal["GET", "POST"], diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 5163dd0ca6d..8e4409daa54 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1053,6 +1053,7 @@ async def test_multiple_runs_wait(hass: HomeAssistant, action_type) -> None: hass.states.async_set("switch.test", "on") hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 1 @@ -1062,6 +1063,7 @@ async def test_multiple_runs_wait(hass: HomeAssistant, action_type) -> None: wait_started_flag.clear() hass.async_create_task(script_obj.async_run()) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() raise @@ -4079,6 +4081,7 @@ async def test_script_mode_2( hass.states.async_set("switch.test", "on") hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 1 @@ -4089,6 +4092,7 @@ async def test_script_mode_2( wait_started_flag.clear() hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.sleep(0) assert script_obj.is_running assert len(events) == 2 From 3178eac9ccdebb267c6e1b3d65cf990d6d079012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 27 Sep 2023 17:20:21 +0200 Subject: [PATCH 885/984] Implement Airzone Cloud Zone climate support (#100792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement Airzone Cloud Zone climate support Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: add entity naming Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: implement requested changes Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/__init__.py | 1 + .../components/airzone_cloud/climate.py | 208 ++++++++++++++++ .../components/airzone_cloud/entity.py | 21 ++ .../snapshots/test_diagnostics.ambr | 125 ++++++++-- .../components/airzone_cloud/test_climate.py | 224 ++++++++++++++++++ tests/components/airzone_cloud/util.py | 117 ++++++++- 6 files changed, 669 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/airzone_cloud/climate.py create mode 100644 tests/components/airzone_cloud/test_climate.py diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 732f159c381..38c764d4889 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -14,6 +14,7 @@ from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.SENSOR, ] diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py new file mode 100644 index 00000000000..18393031ae3 --- /dev/null +++ b/homeassistant/components/airzone_cloud/climate.py @@ -0,0 +1,208 @@ +"""Support for the Airzone Cloud climate.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone_cloud.common import OperationAction, OperationMode, TemperatureUnit +from aioairzone_cloud.const import ( + API_MODE, + API_OPTS, + API_POWER, + API_SETPOINT, + API_UNITS, + API_VALUE, + AZD_ACTION, + AZD_HUMIDITY, + AZD_MASTER, + AZD_MODE, + AZD_MODES, + AZD_POWER, + AZD_TEMP, + AZD_TEMP_SET, + AZD_TEMP_SET_MAX, + AZD_TEMP_SET_MIN, + AZD_TEMP_STEP, + AZD_ZONES, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + +HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { + OperationAction.COOLING: HVACAction.COOLING, + OperationAction.DRYING: HVACAction.DRYING, + OperationAction.FAN: HVACAction.FAN, + OperationAction.HEATING: HVACAction.HEATING, + OperationAction.IDLE: HVACAction.IDLE, + OperationAction.OFF: HVACAction.OFF, +} +HVAC_MODE_LIB_TO_HASS: Final[dict[OperationMode, HVACMode]] = { + OperationMode.STOP: HVACMode.OFF, + OperationMode.COOLING: HVACMode.COOL, + OperationMode.COOLING_AIR: HVACMode.COOL, + OperationMode.COOLING_RADIANT: HVACMode.COOL, + OperationMode.COOLING_COMBINED: HVACMode.COOL, + OperationMode.HEATING: HVACMode.HEAT, + OperationMode.HEAT_AIR: HVACMode.HEAT, + OperationMode.HEAT_RADIANT: HVACMode.HEAT, + OperationMode.HEAT_COMBINED: HVACMode.HEAT, + OperationMode.EMERGENCY_HEAT: HVACMode.HEAT, + OperationMode.VENTILATION: HVACMode.FAN_ONLY, + OperationMode.DRY: HVACMode.DRY, + OperationMode.AUTO: HVACMode.HEAT_COOL, +} +HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = { + HVACMode.OFF: OperationMode.STOP, + HVACMode.COOL: OperationMode.COOLING, + HVACMode.HEAT: OperationMode.HEATING, + HVACMode.FAN_ONLY: OperationMode.VENTILATION, + HVACMode.DRY: OperationMode.DRY, + HVACMode.HEAT_COOL: OperationMode.AUTO, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone climate from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[AirzoneClimate] = [] + + # Zones + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): + entities.append( + AirzoneZoneClimate( + coordinator, + zone_id, + zone_data, + ) + ) + + async_add_entities(entities) + + +class AirzoneClimate(AirzoneEntity, ClimateEntity): + """Define an Airzone Cloud climate.""" + + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + params = { + API_POWER: { + API_VALUE: True, + }, + } + await self._async_update_params(params) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + params = { + API_POWER: { + API_VALUE: False, + }, + } + await self._async_update_params(params) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + params: dict[str, Any] = {} + if ATTR_TEMPERATURE in kwargs: + params[API_SETPOINT] = { + API_VALUE: kwargs[ATTR_TEMPERATURE], + API_OPTS: { + API_UNITS: TemperatureUnit.CELSIUS.value, + }, + } + await self._async_update_params(params) + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update climate attributes.""" + self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) + self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY) + self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ + self.get_airzone_value(AZD_ACTION) + ] + if self.get_airzone_value(AZD_POWER): + self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ + self.get_airzone_value(AZD_MODE) + ] + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_SET_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_SET_MIN) + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + + +class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneClimate): + """Define an Airzone Cloud Zone climate.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + system_zone_id: str, + zone_data: dict, + ) -> None: + """Initialize Airzone Cloud Zone climate.""" + super().__init__(coordinator, system_zone_id, zone_data) + + self._attr_unique_id = system_zone_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + slave_raise = False + + params: dict[str, Any] = {} + if hvac_mode == HVACMode.OFF: + params[API_POWER] = { + API_VALUE: False, + } + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + if mode != self.get_airzone_value(AZD_MODE): + if self.get_airzone_value(AZD_MASTER): + params[API_MODE] = { + API_VALUE: mode.value, + } + else: + slave_raise = True + params[API_POWER] = { + API_VALUE: True, + } + + await self._async_update_params(params) + + if slave_raise: + raise HomeAssistantError(f"Mode can't be changed on slave zone {self.name}") diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 090e81e4170..3214869aaab 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +import logging from typing import Any from aioairzone_cloud.const import ( @@ -15,7 +16,9 @@ from aioairzone_cloud.const import ( AZD_WEBSERVERS, AZD_ZONES, ) +from aioairzone_cloud.exceptions import AirzoneCloudError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -23,6 +26,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator +_LOGGER = logging.getLogger(__name__) + class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Define an Airzone Cloud entity.""" @@ -36,6 +41,10 @@ class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): def get_airzone_value(self, key: str) -> Any: """Return Airzone Cloud entity value by key.""" + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Airzone parameters to Cloud API.""" + raise NotImplementedError + class AirzoneAidooEntity(AirzoneEntity): """Define an Airzone Cloud Aidoo entity.""" @@ -153,3 +162,15 @@ class AirzoneZoneEntity(AirzoneEntity): if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id): value = zone.get(key) return value + + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Zone parameters to Cloud API.""" + _LOGGER.debug("zone=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_zone_id_params(self.zone_id, params) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 94e602ec03b..fb33323378a 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -79,11 +79,29 @@ 'id': 'aidoo1', 'installation': 'installation1', 'is-connected': True, - 'mode': None, + 'mode': 3, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'Bron', - 'power': None, + 'power': False, 'problems': False, 'temperature': 21.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-cool-air': 22.0, + 'temperature-setpoint-hot-air': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-auto-air': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-auto-air': 18.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-hot-air': 16.0, 'temperature-step': 0.5, 'web-server': '11:22:33:44:55:67', 'ws-connected': True, @@ -91,19 +109,28 @@ }), 'groups': dict({ 'group1': dict({ - 'action': 6, + 'action': 1, 'active': True, 'available': True, 'humidity': 27, 'installation': 'installation1', - 'mode': 0, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Group', 'num-devices': 2, - 'power': None, + 'power': True, 'systems': list([ 'system1', ]), 'temperature': 22.5, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, 'zones': list([ 'zone1', @@ -118,11 +145,21 @@ ]), 'available': True, 'installation': 'installation1', - 'mode': 0, + 'mode': 3, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'Aidoo Group', 'num-devices': 1, - 'power': None, + 'power': False, 'temperature': 21.0, + 'temperature-setpoint': 22.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, 'temperature-step': 0.5, }), }), @@ -147,7 +184,13 @@ 'id': 'system1', 'installation': 'installation1', 'is-connected': True, - 'mode': None, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'System 1', 'problems': True, 'system': 1, @@ -189,21 +232,47 @@ }), 'zones': dict({ 'zone1': dict({ - 'action': 6, + 'action': 1, 'active': True, 'available': True, 'humidity': 30, 'id': 'zone1', 'installation': 'installation1', 'is-connected': True, - 'master': None, - 'mode': None, + 'master': True, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Salon', - 'power': None, + 'power': True, 'problems': False, 'system': 1, 'system-id': 'system1', 'temperature': 20.0, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-cool-air': 24.0, + 'temperature-setpoint-dry-air': 24.0, + 'temperature-setpoint-hot-air': 20.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-dry-air': 30.0, + 'temperature-setpoint-max-emerheat-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-max-stop-air': 30.0, + 'temperature-setpoint-max-vent-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-dry-air': 18.0, + 'temperature-setpoint-min-emerheat-air': 15.0, + 'temperature-setpoint-min-hot-air': 15.0, + 'temperature-setpoint-min-stop-air': 15.0, + 'temperature-setpoint-min-vent-air': 15.0, + 'temperature-setpoint-stop-air': 24.0, + 'temperature-setpoint-vent-air': 24.0, 'temperature-step': 0.5, 'web-server': 'webserver1', 'ws-connected': True, @@ -217,14 +286,40 @@ 'id': 'zone2', 'installation': 'installation1', 'is-connected': True, - 'master': None, - 'mode': None, + 'master': False, + 'mode': 2, + 'modes': list([ + 2, + 3, + 4, + 5, + ]), 'name': 'Dormitorio', - 'power': None, + 'power': False, 'problems': False, 'system': 1, 'system-id': 'system1', 'temperature': 25.0, + 'temperature-setpoint': 24.0, + 'temperature-setpoint-cool-air': 24.0, + 'temperature-setpoint-dry-air': 24.0, + 'temperature-setpoint-hot-air': 20.0, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-max-cool-air': 30.0, + 'temperature-setpoint-max-dry-air': 30.0, + 'temperature-setpoint-max-emerheat-air': 30.0, + 'temperature-setpoint-max-hot-air': 30.0, + 'temperature-setpoint-max-stop-air': 30.0, + 'temperature-setpoint-max-vent-air': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-setpoint-min-cool-air': 18.0, + 'temperature-setpoint-min-dry-air': 18.0, + 'temperature-setpoint-min-emerheat-air': 15.0, + 'temperature-setpoint-min-hot-air': 15.0, + 'temperature-setpoint-min-stop-air': 15.0, + 'temperature-setpoint-min-vent-air': 15.0, + 'temperature-setpoint-stop-air': 24.0, + 'temperature-setpoint-vent-air': 24.0, 'temperature-step': 0.5, 'web-server': 'webserver1', 'ws-connected': True, diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py new file mode 100644 index 00000000000..acf1d082c29 --- /dev/null +++ b/tests/components/airzone_cloud/test_climate.py @@ -0,0 +1,224 @@ +"""The climate tests for the Airzone Cloud platform.""" +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError +import pytest + +from homeassistant.components.airzone.const import API_TEMPERATURE_STEP +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_STEP, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .util import async_init_integration + + +async def test_airzone_create_climates(hass: HomeAssistant) -> None: + """Test creation of climates.""" + + await async_init_integration(hass) + + # Zones + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 24 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25.0 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.COOL + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 30 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.0 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + + +async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: + """Test turning on/off.""" + + await async_init_integration(hass) + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.dormitorio", + }, + blocking=True, + ) + + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "climate.salon", + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.OFF + + +async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: + """Test setting the HVAC mode.""" + + await async_init_integration(hass) + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.HEAT + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.state == HVACMode.OFF + + +async def test_airzone_climate_set_hvac_slave_error(hass: HomeAssistant) -> None: + """Test setting the HVAC mode for a slave zone.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.dormitorio", + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + state = hass.states.get("climate.dormitorio") + assert state.state == HVACMode.COOL + + +async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: + """Test setting the target temperature.""" + + await async_init_integration(hass) + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + + +async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: + """Test error when setting the target temperature.""" + + await async_init_integration(hass) + + # Zones + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 8fd7da06853..412f0df1337 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import patch +from aioairzone_cloud.common import OperationMode from aioairzone_cloud.const import ( API_ACTIVE, API_AZ_AIDOO, @@ -24,8 +25,33 @@ from aioairzone_cloud.const import ( API_IS_CONNECTED, API_LOCAL_TEMP, API_META, + API_MODE, + API_MODE_AVAIL, API_NAME, API_OLD_ID, + API_POWER, + API_RANGE_MAX_AIR, + API_RANGE_MIN_AIR, + API_RANGE_SP_MAX_AUTO_AIR, + API_RANGE_SP_MAX_COOL_AIR, + API_RANGE_SP_MAX_DRY_AIR, + API_RANGE_SP_MAX_EMERHEAT_AIR, + API_RANGE_SP_MAX_HOT_AIR, + API_RANGE_SP_MAX_STOP_AIR, + API_RANGE_SP_MAX_VENT_AIR, + API_RANGE_SP_MIN_AUTO_AIR, + API_RANGE_SP_MIN_COOL_AIR, + API_RANGE_SP_MIN_DRY_AIR, + API_RANGE_SP_MIN_EMERHEAT_AIR, + API_RANGE_SP_MIN_HOT_AIR, + API_RANGE_SP_MIN_STOP_AIR, + API_RANGE_SP_MIN_VENT_AIR, + API_SP_AIR_AUTO, + API_SP_AIR_COOL, + API_SP_AIR_DRY, + API_SP_AIR_HEAT, + API_SP_AIR_STOP, + API_SP_AIR_VENT, API_STAT_AP_MAC, API_STAT_CHANNEL, API_STAT_QUALITY, @@ -166,12 +192,29 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: False, API_ERRORS: [], + API_MODE: OperationMode.HEATING.value, + API_MODE_AVAIL: [ + OperationMode.AUTO.value, + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], + API_SP_AIR_AUTO: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_COOL: {API_CELSIUS: 22, API_FAH: 72}, + API_SP_AIR_HEAT: {API_CELSIUS: 22, API_FAH: 72}, + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_AUTO_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_AUTO_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_HOT_AIR: {API_CELSIUS: 16, API_FAH: 61}, + API_POWER: False, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_CELSIUS: 21, - API_FAH: 70, - }, + API_LOCAL_TEMP: {API_CELSIUS: 21, API_FAH: 70}, API_WARNINGS: [], } if device.get_id() == "system1": @@ -181,6 +224,13 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_OLD_ID: "error-id", }, ], + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [ + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], API_IS_CONNECTED: True, API_WS_CONNECTED: True, API_WARNINGS: [], @@ -189,24 +239,67 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: True, API_HUMIDITY: 30, + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [ + OperationMode.COOLING.value, + OperationMode.HEATING.value, + OperationMode.VENTILATION.value, + OperationMode.DRY.value, + ], + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_DRY_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_EMERHEAT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_STOP_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_VENT_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_DRY_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_EMERHEAT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_HOT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_STOP_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_VENT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_SP_AIR_COOL: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_DRY: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_HEAT: {API_CELSIUS: 20, API_FAH: 68}, + API_SP_AIR_VENT: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_STOP: {API_CELSIUS: 24, API_FAH: 75}, + API_POWER: True, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_FAH: 68, - API_CELSIUS: 20, - }, + API_LOCAL_TEMP: {API_FAH: 68, API_CELSIUS: 20}, API_WARNINGS: [], } if device.get_id() == "zone2": return { API_ACTIVE: False, API_HUMIDITY: 24, + API_MODE: OperationMode.COOLING.value, + API_MODE_AVAIL: [], + API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_COOL_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_DRY_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_EMERHEAT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_HOT_AIR: {API_CELSIUS: 30, API_FAH: 86}, + API_RANGE_SP_MAX_STOP_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_SP_MAX_VENT_AIR: {API_FAH: 86, API_CELSIUS: 30}, + API_RANGE_MIN_AIR: {API_CELSIUS: 15, API_FAH: 59}, + API_RANGE_SP_MIN_COOL_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_DRY_AIR: {API_CELSIUS: 18, API_FAH: 64}, + API_RANGE_SP_MIN_EMERHEAT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_HOT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_STOP_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_RANGE_SP_MIN_VENT_AIR: {API_FAH: 59, API_CELSIUS: 15}, + API_SP_AIR_COOL: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_DRY: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_HEAT: {API_CELSIUS: 20, API_FAH: 68}, + API_SP_AIR_VENT: {API_CELSIUS: 24, API_FAH: 75}, + API_SP_AIR_STOP: {API_CELSIUS: 24, API_FAH: 75}, + API_POWER: False, API_IS_CONNECTED: True, API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_FAH: 77, - API_CELSIUS: 25, - }, + API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } return None From b21451b3d12ef4a534eb96d8e6e84931504e6cff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Sep 2023 10:23:06 -0500 Subject: [PATCH 886/984] Bump dbus-fast to 2.11.0 (#101005) --- 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 66299f4fd83..2e2d6fa45ed 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.12.0", - "dbus-fast==2.10.0" + "dbus-fast==2.11.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5f812f14dc8..698960095ba 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bluetooth-data-tools==1.12.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.4 -dbus-fast==2.10.0 +dbus-fast==2.11.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.71.0 diff --git a/requirements_all.txt b/requirements_all.txt index b1cb32b5eec..55d279ae8cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -645,7 +645,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.10.0 +dbus-fast==2.11.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4858e1ec004..b6d3352c69b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.10.0 +dbus-fast==2.11.0 # homeassistant.components.debugpy debugpy==1.8.0 From 577b664c3bf761085e16c969dc1607caaa40644c Mon Sep 17 00:00:00 2001 From: Jeef Date: Wed, 27 Sep 2023 09:28:05 -0600 Subject: [PATCH 887/984] Add WeatherFlow integration (#75530) * merge upstream * Update homeassistant/components/weatherflow/__init__.py Co-authored-by: G Johansson * feat: Removing unused keys * feat: Addressing PR to remove DEFAULT_HOST from init * feat: Addressing PR abort case * feat: Ensure there is a default host always * feat: Addressing PR comments and fixing entity names via local testing * feat: Tested units * feat: updated variable names to hopefully add some clarity to the function * feat: added more var names for clarity * feat: Fixed abort * Update homeassistant/components/weatherflow/__init__.py Co-authored-by: G Johansson * feat: Removed an unnecessary line * feat: Test updates * feat: Removed unreachable code * feat: Tons of improvements * removed debug code * feat: small tweaks * feat: Fixed density into HA Compatibility * feat: Handled the options incorrectly... now fixed * feat: Handled the options incorrectly... now fixed * Update homeassistant/components/weatherflow/manifest.json Co-authored-by: J. Nick Koston * Cleaned up callback in __init__ Cleaning up config_flow as well * feat: Cleaned up a stupid test * feat: Simulating a timeout event * feat: Triggering timeout in mocking * feat: trying to pass tests * refactor: Moved code around so easier to test * Update homeassistant/components/weatherflow/__init__.py Co-authored-by: J. Nick Koston * feat: Incremental changes moving along well * feat: Last fix before re-review * feat: Hopefully the tests shall pass * feat: Remove domian from unique id * feat: Fixed entity name * feat: Removed unneeded lambda - to make thread safe * Working version... * working * working * feat: Remove tuff * feat: Removed dual call * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/strings.json Co-authored-by: Joost Lekkerkerker * feat: updates based on pr * feat: removed some self refrences * feat: Mod RSSI * feat: Removed the custom Air Density (will add in a later PR) * feat: Significant cleanup of config flow * feat: Reworked the configflwo with the help of Joostlek * feat: Updated test coverage * feat: Removing bakcing lib attribute * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * feat: Updated translations * feat: Renamed CUSTOM to VOC * feat: Extreme simplification of config flow * feat: Pushing incremental changes * feat: Fixing test coverage * feat: Added lambda expressions for attributes and removed the custom AirDensity sensor * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: J. Nick Koston * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: J. Nick Koston * feat: Removed default lambda expression from raw_data_conv_fn * feat: Added back default variable for lambda * feat: Updated tests accordingly * feat: Updated tests * made sure to patch correct import * made sure to patch correct import * feat: Fixed up tests ... added missing asserts * feat: Dropped model * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: J. Nick Koston * Refactor: Updated code * refactor: Removed commented code * feat: close out all tests * feat: Fixing the patch * feat: Removed a bunch of stuff * feat: Cleaning up tests even more * fixed patch and paramaterized a test * feat: Addressing most recent comments * updates help of joostlek * feat: Updated coverage for const * Update homeassistant/components/weatherflow/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/weatherflow/sensor.py Co-authored-by: Joost Lekkerkerker * feat: Addressing more PR issues... probably still a few remain * using const logger * Update homeassistant/components/weatherflow/strings.json Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: G Johansson Co-authored-by: J. Nick Koston Co-authored-by: Joost Lekkerkerker --- .coveragerc | 3 + CODEOWNERS | 2 + .../components/weatherflow/__init__.py | 77 ++++ .../components/weatherflow/config_flow.py | 75 ++++ homeassistant/components/weatherflow/const.py | 18 + .../components/weatherflow/manifest.json | 11 + .../components/weatherflow/sensor.py | 386 ++++++++++++++++++ .../components/weatherflow/strings.json | 82 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/weatherflow/__init__.py | 1 + tests/components/weatherflow/conftest.py | 79 ++++ .../weatherflow/fixtures/device.json | 13 + .../weatherflow/test_config_flow.py | 91 +++++ 16 files changed, 851 insertions(+) create mode 100644 homeassistant/components/weatherflow/__init__.py create mode 100644 homeassistant/components/weatherflow/config_flow.py create mode 100644 homeassistant/components/weatherflow/const.py create mode 100644 homeassistant/components/weatherflow/manifest.json create mode 100644 homeassistant/components/weatherflow/sensor.py create mode 100644 homeassistant/components/weatherflow/strings.json create mode 100644 tests/components/weatherflow/__init__.py create mode 100644 tests/components/weatherflow/conftest.py create mode 100644 tests/components/weatherflow/fixtures/device.json create mode 100644 tests/components/weatherflow/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index ed621cbff10..2f899999f41 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1507,6 +1507,9 @@ omit = homeassistant/components/watson_tts/tts.py homeassistant/components/watttime/__init__.py homeassistant/components/watttime/sensor.py + homeassistant/components/weatherflow/__init__.py + homeassistant/components/weatherflow/const.py + homeassistant/components/weatherflow/sensor.py homeassistant/components/wiffi/__init__.py homeassistant/components/wiffi/binary_sensor.py homeassistant/components/wiffi/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index a6823b0fa45..eed0f633df3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1417,6 +1417,8 @@ build.json @home-assistant/supervisor /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core /tests/components/weather/ @home-assistant/core +/homeassistant/components/weatherflow/ @natekspencer @jeeftor +/tests/components/weatherflow/ @natekspencer @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner /homeassistant/components/webhook/ @home-assistant/core diff --git a/homeassistant/components/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py new file mode 100644 index 00000000000..c64450babe7 --- /dev/null +++ b/homeassistant/components/weatherflow/__init__.py @@ -0,0 +1,77 @@ +"""Get data from Smart Weather station via UDP.""" +from __future__ import annotations + +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener +from pyweatherflowudp.device import EVENT_LOAD_COMPLETE, WeatherFlowDevice +from pyweatherflowudp.errors import ListenerError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.start import async_at_started + +from .const import DOMAIN, LOGGER, format_dispatch_call + +PLATFORMS = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up WeatherFlow from a config entry.""" + + client = WeatherFlowListener() + + @callback + def _async_device_discovered(device: WeatherFlowDevice) -> None: + LOGGER.debug("Found a device: %s", device) + + @callback + def _async_add_device_if_started(device: WeatherFlowDevice): + async_at_started( + hass, + callback( + lambda _: async_dispatcher_send( + hass, format_dispatch_call(entry), device + ) + ), + ) + + entry.async_on_unload( + device.on( + EVENT_LOAD_COMPLETE, + lambda _: _async_add_device_if_started(device), + ) + ) + + entry.async_on_unload(client.on(EVENT_DEVICE_DISCOVERED, _async_device_discovered)) + + try: + await client.start_listening() + except ListenerError as ex: + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_handle_ha_shutdown(event: Event) -> None: + """Handle HA shutdown.""" + await client.stop_listening() + + entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_handle_ha_shutdown) + ) + + 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): + client: WeatherFlowListener = hass.data[DOMAIN].pop(entry.entry_id, None) + if client: + await client.stop_listening() + + return unload_ok diff --git a/homeassistant/components/weatherflow/config_flow.py b/homeassistant/components/weatherflow/config_flow.py new file mode 100644 index 00000000000..5ce737810b0 --- /dev/null +++ b/homeassistant/components/weatherflow/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for WeatherFlow.""" +from __future__ import annotations + +import asyncio +from asyncio import Future +from asyncio.exceptions import CancelledError +from typing import Any + +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED, WeatherFlowListener +from pyweatherflowudp.errors import AddressInUseError, EndpointError, ListenerError + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + DOMAIN, + ERROR_MSG_ADDRESS_IN_USE, + ERROR_MSG_CANNOT_CONNECT, + ERROR_MSG_NO_DEVICE_FOUND, +) + + +async def _async_can_discover_devices() -> bool: + """Return if there are devices that can be discovered.""" + future_event: Future[None] = asyncio.get_running_loop().create_future() + + @callback + def _async_found(_): + """Handle a discovered device - only need to do this once so.""" + + if not future_event.done(): + future_event.set_result(None) + + async with WeatherFlowListener() as client, asyncio.timeout(10): + try: + client.on(EVENT_DEVICE_DISCOVERED, _async_found) + await future_event + except asyncio.TimeoutError: + return False + + return True + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for WeatherFlow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + + # Only allow a single instance of integration since the listener + # will pick up all devices on the network and we don't want to + # create multiple entries. + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + found = False + errors = {} + try: + found = await _async_can_discover_devices() + except AddressInUseError: + errors["base"] = ERROR_MSG_ADDRESS_IN_USE + except (ListenerError, EndpointError, CancelledError): + errors["base"] = ERROR_MSG_CANNOT_CONNECT + + if not found and not errors: + errors["base"] = ERROR_MSG_NO_DEVICE_FOUND + + if errors: + return self.async_show_form(step_id="user", errors=errors) + + return self.async_create_entry(title="WeatherFlow", data={}) diff --git a/homeassistant/components/weatherflow/const.py b/homeassistant/components/weatherflow/const.py new file mode 100644 index 00000000000..fdacc6ef1eb --- /dev/null +++ b/homeassistant/components/weatherflow/const.py @@ -0,0 +1,18 @@ +"""Constants for the WeatherFlow integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry + +DOMAIN = "weatherflow" +LOGGER = logging.getLogger(__package__) + + +def format_dispatch_call(config_entry: ConfigEntry) -> str: + """Construct a dispatch call from a ConfigEntry.""" + return f"{config_entry.domain}_{config_entry.entry_id}_add" + + +ERROR_MSG_ADDRESS_IN_USE = "address_in_use" +ERROR_MSG_CANNOT_CONNECT = "cannot_connect" +ERROR_MSG_NO_DEVICE_FOUND = "no_device_found" diff --git a/homeassistant/components/weatherflow/manifest.json b/homeassistant/components/weatherflow/manifest.json new file mode 100644 index 00000000000..e2671d74cda --- /dev/null +++ b/homeassistant/components/weatherflow/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "weatherflow", + "name": "WeatherFlow", + "codeowners": ["@natekspencer", "@jeeftor"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/weatherflow", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["pyweatherflowudp"], + "requirements": ["pyweatherflowudp==1.4.2"] +} diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py new file mode 100644 index 00000000000..dfc8e585f1b --- /dev/null +++ b/homeassistant/components/weatherflow/sensor.py @@ -0,0 +1,386 @@ +"""Sensors for the weatherflow integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum + +from pyweatherflowudp.const import EVENT_RAPID_WIND +from pyweatherflowudp.device import ( + EVENT_OBSERVATION, + EVENT_STATUS_UPDATE, + WeatherFlowDevice, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UV_INDEX, + EntityCategory, + UnitOfElectricPotential, + UnitOfIrradiance, + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DOMAIN, LOGGER, format_dispatch_call + + +@dataclass +class WeatherFlowSensorRequiredKeysMixin: + """Mixin for required keys.""" + + raw_data_conv_fn: Callable[[WeatherFlowDevice], datetime | StateType] + + +def precipitation_raw_conversion_fn(raw_data: Enum): + """Parse parse precipitation type.""" + if raw_data.name.lower() == "unknown": + return None + return raw_data.name.lower() + + +@dataclass +class WeatherFlowSensorEntityDescription( + SensorEntityDescription, WeatherFlowSensorRequiredKeysMixin +): + """Describes WeatherFlow sensor entity.""" + + event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION]) + imperial_suggested_unit: None | str = None + + def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: + """Return the parsed sensor value.""" + raw_sensor_data = getattr(device, self.key) + return self.raw_data_conv_fn(raw_sensor_data) + + +SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( + WeatherFlowSensorEntityDescription( + key="air_density", + translation_key="air_density", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + raw_data_conv_fn=lambda raw_data: raw_data.m * 1000000, + ), + WeatherFlowSensorEntityDescription( + key="air_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="dew_point_temperature", + translation_key="dew_point", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="feels_like_temperature", + translation_key="feels_like", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wet_bulb_temperature", + translation_key="wet_bulb_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="battery", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_average_distance", + icon="mdi:lightning-bolt", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + translation_key="lightning_average_distance", + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_count", + translation_key="lightning_count", + icon="mdi:lightning-bolt", + state_class=SensorStateClass.TOTAL, + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="precipitation_type", + translation_key="precipitation_type", + device_class=SensorDeviceClass.ENUM, + options=["none", "rain", "hail", "rain_hail", "unknown"], + icon="mdi:weather-rainy", + raw_data_conv_fn=precipitation_raw_conversion_fn, + ), + WeatherFlowSensorEntityDescription( + key="rain_accumulation_previous_minute", + icon="mdi:weather-rainy", + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.PRECIPITATION, + imperial_suggested_unit=UnitOfPrecipitationDepth.INCHES, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="rain_rate", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + icon="mdi:weather-rainy", + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="relative_humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + event_subscriptions=[EVENT_STATUS_UPDATE], + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="station_pressure", + translation_key="station_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + imperial_suggested_unit=UnitOfPressure.INHG, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="solar_radiation", + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="up_since", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + event_subscriptions=[EVENT_STATUS_UPDATE], + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="uv", + translation_key="uv_index", + native_unit_of_measurement=UV_INDEX, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data, + ), + WeatherFlowSensorEntityDescription( + key="vapor_pressure", + translation_key="vapor_pressure", + native_unit_of_measurement=UnitOfPressure.MBAR, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + imperial_suggested_unit=UnitOfPressure.INHG, + suggested_display_precision=5, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + ## Wind Sensors + WeatherFlowSensorEntityDescription( + key="wind_gust", + translation_key="wind_gust", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_lull", + translation_key="wind_lull", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + icon="mdi:weather-windy", + event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_speed_average", + translation_key="wind_speed_average", + icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_direction", + translation_key="wind_direction", + icon="mdi:compass-outline", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="wind_direction_average", + translation_key="wind_direction_average", + icon="mdi:compass-outline", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up WeatherFlow sensors using config entry.""" + + @callback + def async_add_sensor(device: WeatherFlowDevice) -> None: + """Add WeatherFlow sensor.""" + LOGGER.debug("Adding sensors for %s", device) + + sensors: list[WeatherFlowSensorEntity] = [ + WeatherFlowSensorEntity( + device=device, + description=description, + is_metric=(hass.config.units == METRIC_SYSTEM), + ) + for description in SENSORS + if hasattr(device, description.key) + ] + + async_add_entities(sensors) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + format_dispatch_call(config_entry), + async_add_sensor, + ) + ) + + +class WeatherFlowSensorEntity(SensorEntity): + """Defines a WeatherFlow sensor entity.""" + + entity_description: WeatherFlowSensorEntityDescription + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + device: WeatherFlowDevice, + description: WeatherFlowSensorEntityDescription, + is_metric: bool = True, + ) -> None: + """Initialize a WeatherFlow sensor entity.""" + self.device = device + self.entity_description = description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.serial_number)}, + manufacturer="WeatherFlow", + model=device.model, + name=device.serial_number, + sw_version=device.firmware_revision, + ) + + self._attr_unique_id = f"{device.serial_number}_{description.key}" + + # In the case of the USA - we may want to have a suggested US unit which differs from the internal suggested units + if description.imperial_suggested_unit is not None and not is_metric: + self._attr_suggested_unit_of_measurement = ( + description.imperial_suggested_unit + ) + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + 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) + + async def async_added_to_hass(self) -> None: + """Subscribe to events.""" + for event in self.entity_description.event_subscriptions: + self.async_on_remove( + self.device.on(event, lambda _: self.async_write_ha_state()) + ) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json new file mode 100644 index 00000000000..8f7a98abe04 --- /dev/null +++ b/homeassistant/components/weatherflow/strings.json @@ -0,0 +1,82 @@ +{ + "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%]" + } + } + }, + "error": { + "address_in_use": "Unable to open local UDP port 50222.", + "cannot_connect": "UDP discovery error." + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + }, + "entity": { + "sensor": { + "air_density": { + "name": "Air density" + }, + "dew_point": { + "name": "Dew point" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "feels_like": { + "name": "Feels like" + }, + "lightning_average_distance": { + "name": "Lightning average distance" + }, + "lightning_count": { + "name": "Lightning count" + }, + "precipitation_type": { + "name": "Precipitation type", + "state": { + "none": "None", + "rain": "Rain", + "hail": "Hail", + "rain_hail": "Rain and hail" + } + }, + "station_pressure": { + "name": "Air pressure" + }, + "uptime": { + "name": "Uptime" + }, + "uv_index": { + "name": "UV index" + }, + "vapor_pressure": { + "name": "Vapor pressure" + }, + "wet_bulb_temperature": { + "name": "Wet bulb temperature" + }, + "wind_speed_average": { + "name": "Wind speed average" + }, + "wind_direction": { + "name": "Wind direction" + }, + "wind_direction_average": { + "name": "Wind direction average" + }, + "wind_gust": { + "name": "Wind gust" + }, + "wind_lull": { + "name": "Wind lull" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 552e7cf991c..ef22ac4f653 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -529,6 +529,7 @@ FLOWS = { "waqi", "watttime", "waze_travel_time", + "weatherflow", "weatherkit", "webostv", "wemo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8215554784f..1d9c2208ad0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6347,6 +6347,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "weatherflow": { + "name": "WeatherFlow", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "webhook": { "name": "Webhook", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 55d279ae8cc..dd549ea51d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2248,6 +2248,9 @@ pyvolumio==0.1.5 # homeassistant.components.waze_travel_time pywaze==0.5.0 +# homeassistant.components.weatherflow +pyweatherflowudp==1.4.2 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6d3352c69b..dd81cfb85d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1674,6 +1674,9 @@ pyvolumio==0.1.5 # homeassistant.components.waze_travel_time pywaze==0.5.0 +# homeassistant.components.weatherflow +pyweatherflowudp==1.4.2 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/weatherflow/__init__.py b/tests/components/weatherflow/__init__.py new file mode 100644 index 00000000000..e7dd3dc0958 --- /dev/null +++ b/tests/components/weatherflow/__init__.py @@ -0,0 +1 @@ +"""Tests for the WeatherFlow integration.""" diff --git a/tests/components/weatherflow/conftest.py b/tests/components/weatherflow/conftest.py new file mode 100644 index 00000000000..0bf6b69b9a7 --- /dev/null +++ b/tests/components/weatherflow/conftest.py @@ -0,0 +1,79 @@ +"""Fixtures for Weatherflow integration tests.""" +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED +from pyweatherflowudp.device import WeatherFlowDevice + +from homeassistant.components.weatherflow.const import DOMAIN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.weatherflow.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry(domain=DOMAIN, data={}) + + +@pytest.fixture +def mock_has_devices() -> Generator[AsyncMock, None, None]: + """Return a mock has_devices function.""" + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", + return_value=True, + ) as mock_has_devices: + yield mock_has_devices + + +@pytest.fixture +def mock_stop() -> Generator[AsyncMock, None, None]: + """Return a fixture to handle the stop of udp.""" + + async def mock_stop_listening(self): + self._udp_task.cancel() + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.stop_listening", + autospec=True, + side_effect=mock_stop_listening, + ) as mock_function: + yield mock_function + + +@pytest.fixture +def mock_start() -> Generator[AsyncMock, None, None]: + """Return fixture for starting upd.""" + + device = WeatherFlowDevice( + serial_number="HB-00000001", + data=load_json_object_fixture("weatherflow/device.json"), + ) + + async def device_discovery_task(self): + await asyncio.gather( + await asyncio.sleep(0.1), self.emit(EVENT_DEVICE_DISCOVERED, "HB-00000001") + ) + + async def mock_start_listening(self): + """Mock listening function.""" + self._devices["HB-00000001"] = device + self._udp_task = asyncio.create_task(device_discovery_task(self)) + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.start_listening", + autospec=True, + side_effect=mock_start_listening, + ) as mock_function: + yield mock_function diff --git a/tests/components/weatherflow/fixtures/device.json b/tests/components/weatherflow/fixtures/device.json new file mode 100644 index 00000000000..a9653c71cb0 --- /dev/null +++ b/tests/components/weatherflow/fixtures/device.json @@ -0,0 +1,13 @@ +{ + "serial_number": "ST-00000001", + "type": "device_status", + "hub_sn": "HB-00000001", + "timestamp": 1510855923, + "uptime": 2189, + "voltage": 3.5, + "firmware_revision": 17, + "rssi": -17, + "hub_rssi": -87, + "sensor_status": 0, + "debug": 0 +} diff --git a/tests/components/weatherflow/test_config_flow.py b/tests/components/weatherflow/test_config_flow.py new file mode 100644 index 00000000000..4188c737230 --- /dev/null +++ b/tests/components/weatherflow/test_config_flow.py @@ -0,0 +1,91 @@ +"""Tests for WeatherFlow.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from pyweatherflowudp.errors import AddressInUseError + +from homeassistant import config_entries +from homeassistant.components.weatherflow.const import ( + DOMAIN, + ERROR_MSG_ADDRESS_IN_USE, + ERROR_MSG_CANNOT_CONNECT, + ERROR_MSG_NO_DEVICE_FOUND, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_single_instance( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_has_devices: AsyncMock, +) -> None: + """Test more than one instance.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_devices_with_mocks( + hass: HomeAssistant, + mock_start: AsyncMock, + mock_stop: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test getting user input.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {} + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + (asyncio.TimeoutError, ERROR_MSG_NO_DEVICE_FOUND), + (asyncio.exceptions.CancelledError, ERROR_MSG_CANNOT_CONNECT), + (AddressInUseError, ERROR_MSG_ADDRESS_IN_USE), + ], +) +async def test_devices_with_various_mocks_errors( + hass: HomeAssistant, + mock_start: AsyncMock, + mock_stop: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error_msg: str, +) -> None: + """Test the various on error states - then finally complete the test.""" + + with patch( + "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == error_msg + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {} From 554118196942a4c3959601f8860089f71151b177 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 27 Sep 2023 17:38:14 +0200 Subject: [PATCH 888/984] Address Comelit cover late review (#101008) address late review --- homeassistant/components/comelit/__init__.py | 2 +- homeassistant/components/comelit/cover.py | 4 ++-- homeassistant/components/comelit/light.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 1a0d49f0666..4a105072802 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import ComelitSerialBridge -PLATFORMS = [Platform.LIGHT, Platform.COVER] +PLATFORMS = [Platform.COVER, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 022ce05ff6d..0135fa3984a 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -43,13 +43,13 @@ class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): self, coordinator: ComelitSerialBridge, device: ComelitSerialBridgeObject, - config_entry_unique_id: str, + config_entry_entry_id: str, ) -> None: """Init cover entity.""" self._api = coordinator.api self._device = device super().__init__(coordinator) - self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device, COVER) # Device doesn't provide a status so we assume CLOSE at startup self._last_action = COVER_STATUS.index("closing") diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 64fb081243a..a59422f7b04 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -42,13 +42,13 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): self, coordinator: ComelitSerialBridge, device: ComelitSerialBridgeObject, - config_entry_unique_id: str, + config_entry_entry_id: str, ) -> None: """Init light entity.""" self._api = coordinator.api self._device = device super().__init__(coordinator) - self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = self.coordinator.platform_device_info(device, LIGHT) async def _light_set_state(self, state: int) -> None: From 4066f657d3da2bf61f3428581c444a4e59f0a205 Mon Sep 17 00:00:00 2001 From: Diogo Morgado Date: Wed, 27 Sep 2023 16:45:21 +0100 Subject: [PATCH 889/984] Add "UV Index" to IPMA (#100383) * Bump pyipma to 3.0.7 * Add uv index sensor to IPMA --- homeassistant/components/ipma/manifest.json | 2 +- homeassistant/components/ipma/sensor.py | 23 +++++++++++++++++---- homeassistant/components/ipma/strings.json | 3 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ipma/__init__.py | 8 +++++++ tests/components/ipma/test_sensor.py | 19 +++++++++++++---- 7 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 4fea047e834..0d7df3fcf92 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ipma", "iot_class": "cloud_polling", "loggers": ["geopy", "pyipma"], - "requirements": ["pyipma==3.0.6"] + "requirements": ["pyipma==3.0.7"] } diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 7f5782f3f89..cb0620ceca0 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -8,6 +8,8 @@ import logging from pyipma.api import IPMA_API from pyipma.location import Location +from pyipma.rcm import RCM +from pyipma.uv import UV from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry @@ -33,19 +35,32 @@ class IPMASensorEntityDescription(SensorEntityDescription, IPMARequiredKeysMixin """Describes IPMA sensor entity.""" -async def async_retrive_rcm(location: Location, api: IPMA_API) -> int | None: +async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: """Retrieve RCM.""" - fire_risk = await location.fire_risk(api) + fire_risk: RCM = await location.fire_risk(api) if fire_risk: return fire_risk.rcm return None +async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None: + """Retrieve UV.""" + uv_risk: UV = await location.uv_risk(api) + if uv_risk: + return round(uv_risk.iUv) + return None + + SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( IPMASensorEntityDescription( key="rcm", translation_key="fire_risk", - value_fn=async_retrive_rcm, + value_fn=async_retrieve_rcm, + ), + IPMASensorEntityDescription( + key="uvi", + translation_key="uv_index", + value_fn=async_retrieve_uvi, ), ) @@ -81,7 +96,7 @@ class IPMASensor(SensorEntity, IPMADevice): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: - """Update Fire risk.""" + """Update sensors.""" async with asyncio.timeout(10): self._attr_native_value = await self.entity_description.value_fn( self._location, self._api diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index b9b672e77d9..ea5e5ff4759 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -28,6 +28,9 @@ "sensor": { "fire_risk": { "name": "Fire risk" + }, + "uv_index": { + "name": "UV index" } } } diff --git a/requirements_all.txt b/requirements_all.txt index dd549ea51d9..4bb5909b09c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1764,7 +1764,7 @@ pyinsteon==1.5.1 pyintesishome==1.8.0 # homeassistant.components.ipma -pyipma==3.0.6 +pyipma==3.0.7 # homeassistant.components.ipp pyipp==0.14.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd81cfb85d6..c4829c59a61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1325,7 +1325,7 @@ pyicloud==1.0.0 pyinsteon==1.5.1 # homeassistant.components.ipma -pyipma==3.0.6 +pyipma==3.0.7 # homeassistant.components.ipp pyipp==0.14.4 diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index 827481c60de..02a61f0b201 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -27,6 +27,14 @@ class MockLocation: ) return RCM("some place", 3, (0, 0)) + async def uv_risk(self, api): + """Mock UV Index.""" + UV = namedtuple( + "UV", + ["idPeriodo", "intervaloHora", "data", "globalIdLocal", "iUv"], + ) + return UV(0, "0", datetime.now(), 0, 5.7) + async def observation(self, api): """Mock Observation.""" Observation = namedtuple( diff --git a/tests/components/ipma/test_sensor.py b/tests/components/ipma/test_sensor.py index cbbad9c590f..d5f6a3ab5bb 100644 --- a/tests/components/ipma/test_sensor.py +++ b/tests/components/ipma/test_sensor.py @@ -10,10 +10,7 @@ from tests.common import MockConfigEntry async def test_ipma_fire_risk_create_sensors(hass): """Test creation of fire risk sensors.""" - with patch( - "pyipma.location.Location.get", - return_value=MockLocation(), - ): + with patch("pyipma.location.Location.get", return_value=MockLocation()): entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -22,3 +19,17 @@ async def test_ipma_fire_risk_create_sensors(hass): state = hass.states.get("sensor.hometown_fire_risk") assert state.state == "3" + + +async def test_ipma_uv_index_create_sensors(hass): + """Test creation of uv index sensors.""" + + with patch("pyipma.location.Location.get", return_value=MockLocation()): + entry = MockConfigEntry(domain="ipma", data=ENTRY_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hometown_uv_index") + + assert state.state == "6" From e7fbd3b54bb4745d2abe73e2e6ec4d5bd49a47ed Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Sep 2023 18:14:30 +0200 Subject: [PATCH 890/984] Bumped version to 2023.10.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 5585413e97b..0865e105f7a 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 = 10 -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 e4d3876d9f7..c620af3200f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0.dev0" +version = "2023.10.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 10e8173d4e68e505df7581cb1f4782d29239bb9e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 27 Sep 2023 18:28:27 +0200 Subject: [PATCH 891/984] Restore state of trend sensor (#100332) * Restoring state of trend sensor * Handle unknown state & parametrize tests --- .../components/trend/binary_sensor.py | 10 ++++++- tests/components/trend/test_binary_sensor.py | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 089e82b0f07..2d00f35202c 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -25,6 +25,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_SENSORS, + STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -37,6 +38,7 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from homeassistant.util.dt import utcnow @@ -116,7 +118,7 @@ async def async_setup_platform( async_add_entities(sensors) -class SensorTrend(BinarySensorEntity): +class SensorTrend(BinarySensorEntity, RestoreEntity): """Representation of a trend Sensor.""" _attr_should_poll = False @@ -194,6 +196,12 @@ class SensorTrend(BinarySensorEntity): ) ) + if not (state := await self.async_get_last_state()): + return + if state.state == STATE_UNKNOWN: + return + self._state = state.state == STATE_ON + async def async_update(self) -> None: """Get the latest data and update the states.""" # Remove outdated samples diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index c477b9a11fe..cccf1add61b 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -2,16 +2,19 @@ from datetime import timedelta from unittest.mock import patch +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.core import HomeAssistant +from homeassistant.core import HomeAssistant, State import homeassistant.util.dt as dt_util from tests.common import ( assert_setup_component, get_fixture_path, get_test_home_assistant, + mock_restore_cache, ) @@ -413,3 +416,28 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.test_trend_sensor") is None assert hass.states.get("binary_sensor.second_test_trend_sensor") + + +@pytest.mark.parametrize( + ("saved_state", "restored_state"), + [("on", "on"), ("off", "off"), ("unknown", "unknown")], +) +async def test_restore_state( + hass: HomeAssistant, 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( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state From dde4b07c29c5cef2e6787c8ab354f5a5197c13cc Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Wed, 27 Sep 2023 20:16:00 +0200 Subject: [PATCH 892/984] Add homeassistant reload_all translatable service name and description (#100437) * Update services.yaml * Update strings.json * Update strings.json --- homeassistant/components/homeassistant/services.yaml | 2 ++ homeassistant/components/homeassistant/strings.json | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 2b5fd3fc686..892e577490d 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -60,3 +60,5 @@ reload_config_entry: text: save_persistent_states: + +reload_all: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 53510a94f01..a3435a8d1f5 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -125,6 +125,10 @@ "save_persistent_states": { "name": "Save persistent states", "description": "Saves the persistent states immediately. Maintains the normal periodic saving interval." + }, + "reload_all": { + "name": "Reload all", + "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." } } } From 415042f356cb77f136d7abbcc93c8bec9fb85c1d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 27 Sep 2023 23:36:12 +0200 Subject: [PATCH 893/984] Adopt Hue integration to latest changes in Hue firmware (#101001) --- homeassistant/components/hue/manifest.json | 2 +- .../components/hue/v2/binary_sensor.py | 61 ++++++++-- homeassistant/components/hue/v2/sensor.py | 14 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/hue/fixtures/v2_resources.json | 108 ++++++++++++++++++ tests/components/hue/test_binary_sensor.py | 50 +++++++- tests/components/hue/test_sensor_v2.py | 2 - tests/components/hue/test_switch.py | 4 +- 9 files changed, 216 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index e55bd2782df..4cd6ca143cb 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,6 @@ "iot_class": "local_push", "loggers": ["aiohue"], "quality_scale": "platinum", - "requirements": ["aiohue==4.6.2"], + "requirements": ["aiohue==4.7.0"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 0a8f50b8b7a..1eded0429b8 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Hue binary sensors.""" from __future__ import annotations -from typing import Any, TypeAlias +from typing import TypeAlias from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.config import ( @@ -9,9 +9,17 @@ from aiohue.v2.controllers.config import ( EntertainmentConfigurationController, ) from aiohue.v2.controllers.events import EventType -from aiohue.v2.controllers.sensors import MotionController +from aiohue.v2.controllers.sensors import ( + CameraMotionController, + ContactController, + MotionController, + TamperController, +) +from aiohue.v2.models.camera_motion import CameraMotion +from aiohue.v2.models.contact import Contact, ContactState from aiohue.v2.models.entertainment_configuration import EntertainmentStatus from aiohue.v2.models.motion import Motion +from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -25,8 +33,16 @@ from ..bridge import HueBridge from ..const import DOMAIN from .entity import HueBaseEntity -SensorType: TypeAlias = Motion | EntertainmentConfiguration -ControllerType: TypeAlias = MotionController | EntertainmentConfigurationController +SensorType: TypeAlias = ( + CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper +) +ControllerType: TypeAlias = ( + CameraMotionController + | ContactController + | MotionController + | EntertainmentConfigurationController + | TamperController +) async def async_setup_entry( @@ -57,8 +73,11 @@ async def async_setup_entry( ) # setup for each binary-sensor-type hue resource + register_items(api.sensors.camera_motion, HueMotionSensor) register_items(api.sensors.motion, HueMotionSensor) register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) + register_items(api.sensors.contact, HueContactSensor) + register_items(api.sensors.tamper, HueTamperSensor) class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): @@ -87,12 +106,7 @@ class HueMotionSensor(HueBinarySensorBase): if not self.resource.enabled: # Force None (unknown) if the sensor is set to disabled in Hue return None - return self.resource.motion.motion - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the optional state attributes.""" - return {"motion_valid": self.resource.motion.motion_valid} + return self.resource.motion.value class HueEntertainmentActiveSensor(HueBinarySensorBase): @@ -110,3 +124,30 @@ class HueEntertainmentActiveSensor(HueBinarySensorBase): """Return sensor name.""" type_title = self.resource.type.value.replace("_", " ").title() return f"{self.resource.metadata.name}: {type_title}" + + +class HueContactSensor(HueBinarySensorBase): + """Representation of a Hue Contact sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OPENING + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.resource.enabled: + # Force None (unknown) if the sensor is set to disabled in Hue + return None + return self.resource.contact_report.state != ContactState.CONTACT + + +class HueTamperSensor(HueBinarySensorBase): + """Representation of a Hue Tamper sensor.""" + + _attr_device_class = BinarySensorDeviceClass.TAMPER + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.resource.tamper_reports: + return False + return self.resource.tamper_reports[0].state == TamperState.TAMPERED diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index cc36edb88b2..4bfb727b917 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -100,12 +100,7 @@ class HueTemperatureSensor(HueSensorBase): @property def native_value(self) -> float: """Return the value reported by the sensor.""" - return round(self.resource.temperature.temperature, 1) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the optional state attributes.""" - return {"temperature_valid": self.resource.temperature.temperature_valid} + return round(self.resource.temperature.value, 1) class HueLightLevelSensor(HueSensorBase): @@ -122,14 +117,13 @@ class HueLightLevelSensor(HueSensorBase): # scale used because the human eye adjusts to light levels and small # changes at low lux levels are more noticeable than at high lux # levels. - return int(10 ** ((self.resource.light.light_level - 1) / 10000)) + return int(10 ** ((self.resource.light.value - 1) / 10000)) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { - "light_level": self.resource.light.light_level, - "light_level_valid": self.resource.light.light_level_valid, + "light_level": self.resource.light.value, } @@ -149,6 +143,8 @@ class HueBatterySensor(HueSensorBase): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" + if self.resource.power_state.battery_state is None: + return {} return {"battery_state": self.resource.power_state.battery_state.value} diff --git a/requirements_all.txt b/requirements_all.txt index 4bb5909b09c..d9dc9f6439b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -256,7 +256,7 @@ aiohomekit==3.0.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.2 +aiohue==4.7.0 # homeassistant.components.imap aioimaplib==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4829c59a61..76b6ca777ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -234,7 +234,7 @@ aiohomekit==3.0.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.2 +aiohue==4.7.0 # homeassistant.components.imap aioimaplib==1.0.1 diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 371975e12a5..24f433f539c 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2221,5 +2221,113 @@ "id": "52612630-841e-4d39-9763-60346a0da759", "is_configured": true, "type": "geolocation" + }, + { + "id": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "product_data": { + "model_id": "SOC001", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": "Hue secure contact sensor", + "product_archetype": "unknown_archetype", + "certified": true, + "software_version": "2.67.9", + "hardware_platform_type": "100b-125" + }, + "metadata": { + "name": "Test contact sensor", + "archetype": "unknown_archetype" + }, + "identify": {}, + "services": [ + { + "rid": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "rtype": "contact" + }, + { + "rid": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "rtype": "tamper" + } + ], + "type": "device" + }, + { + "id": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "owner": { + "rid": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "rtype": "device" + }, + "enabled": true, + "contact_report": { + "changed": "2023-09-27T10:01:36.968Z", + "state": "contact" + }, + "type": "contact" + }, + { + "id": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "owner": { + "rid": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "rtype": "device" + }, + "tamper_reports": [ + { + "changed": "2023-09-25T10:02:08.774Z", + "source": "battery_door", + "state": "not_tampered" + } + ], + "type": "tamper" + }, + { + "id": "1cbda90c-b675-46b0-9e97-278e7e7857ed", + "id_v1": "/sensors/249", + "product_data": { + "model_id": "CAMERA", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": "Fake Hue Test Camera", + "product_archetype": "unknown_archetype", + "certified": true, + "software_version": "0.0.0", + "hardware_platform_type": "0" + }, + "metadata": { + "name": "Test Camera", + "archetype": "unknown_archetype" + }, + "identify": {}, + "usertest": { + "status": "set", + "usertest": false + }, + "services": [ + { + "rid": "d9f2cfee-5879-426b-aa1f-553af8f38176", + "rtype": "camera_motion" + } + ], + "type": "device" + }, + { + "id": "d9f2cfee-5879-426b-aa1f-553af8f38176", + "id_v1": "/sensors/249", + "owner": { + "rid": "1cbda90c-b675-46b0-9e97-278e7e7857ed", + "rtype": "device" + }, + "enabled": true, + "motion": { + "motion": true, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-27T10:06:41.822Z", + "motion": true + } + }, + "sensitivity": { + "status": "set", + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "motion" } ] diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 7750f4a6795..3846f17aa76 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -14,8 +14,8 @@ async def test_binary_sensors( await setup_platform(hass, mock_bridge_v2, "binary_sensor") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 binary_sensors should be created from test data - assert len(hass.states.async_all()) == 2 + # 5 binary_sensors should be created from test data + assert len(hass.states.async_all()) == 5 # test motion sensor sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") @@ -23,7 +23,6 @@ async def test_binary_sensors( assert sensor.state == "off" assert sensor.name == "Hue motion sensor Motion" assert sensor.attributes["device_class"] == "motion" - assert sensor.attributes["motion_valid"] is True # test entertainment room active sensor sensor = hass.states.get( @@ -34,6 +33,51 @@ async def test_binary_sensors( assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" assert sensor.attributes["device_class"] == "running" + # test contact sensor + sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Test contact sensor Contact" + assert sensor.attributes["device_class"] == "opening" + # test contact sensor disabled == state unknown + mock_bridge_v2.api.emit_event( + "update", + { + "enabled": False, + "id": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "type": "contact", + }, + ) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + assert sensor.state == "unknown" + + # test tamper sensor + sensor = hass.states.get("binary_sensor.test_contact_sensor_tamper") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Test contact sensor Tamper" + assert sensor.attributes["device_class"] == "tamper" + # test tamper sensor when no tamper reports exist + mock_bridge_v2.api.emit_event( + "update", + { + "id": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "tamper_reports": [], + "type": "tamper", + }, + ) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.test_contact_sensor_tamper") + assert sensor.state == "off" + + # test camera_motion sensor + sensor = hass.states.get("binary_sensor.test_camera_motion") + assert sensor is not None + assert sensor.state == "on" + assert sensor.name == "Test Camera Motion" + assert sensor.attributes["device_class"] == "motion" + async def test_binary_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: """Test if binary_sensor get added/updated from events.""" diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 91eccc2c984..45e39e94119 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -28,7 +28,6 @@ async def test_sensors( assert sensor.attributes["device_class"] == "temperature" assert sensor.attributes["state_class"] == "measurement" assert sensor.attributes["unit_of_measurement"] == "°C" - assert sensor.attributes["temperature_valid"] is True # test illuminance sensor sensor = hass.states.get("sensor.hue_motion_sensor_illuminance") @@ -39,7 +38,6 @@ async def test_sensors( assert sensor.attributes["state_class"] == "measurement" assert sensor.attributes["unit_of_measurement"] == "lx" assert sensor.attributes["light_level"] == 18027 - assert sensor.attributes["light_level_valid"] is True # test battery sensor sensor = hass.states.get("sensor.wall_switch_with_2_controls_battery") diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index c8fa417b12c..a576b88a7c3 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -14,8 +14,8 @@ async def test_switch( await setup_platform(hass, mock_bridge_v2, "switch") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 entities should be created from test data - assert len(hass.states.async_all()) == 2 + # 3 entities should be created from test data + assert len(hass.states.async_all()) == 3 # test config switch to enable/disable motion sensor test_entity = hass.states.get("switch.hue_motion_sensor_motion") From 115c3d6e49f4574f5f99ae319ed60ef804415bd7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 28 Sep 2023 02:59:19 +0200 Subject: [PATCH 894/984] Fix handling reload with invalid mqtt config (#101015) Fix handling reload whith invalid mqtt config --- homeassistant/components/mqtt/__init__.py | 13 +++++-- tests/components/mqtt/test_init.py | 41 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 5b5c39e6831..7caeb2b51f7 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import TemplateError, Unauthorized +from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -364,8 +364,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" - # Fetch updated manual configured items and validate - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + # Fetch updated manually configured items and validate + if ( + config_yaml := await async_integration_yaml_config(hass, DOMAIN) + ) is None: + # Raise in case we have an invalid configuration + raise HomeAssistantError( + "Error reloading manually configured MQTT items, " + "check your configuration.yaml" + ) mqtt_data.config = config_yaml.get(DOMAIN, {}) # Reload the modern yaml platforms diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index e3a12a2c24e..48d949ae927 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3898,3 +3898,44 @@ async def test_reload_config_entry( assert state.state == "manual2_update_after_reload" assert (state := hass.states.get("sensor.test_manual3")) is not None assert state.state is STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "sensor": { + "name": "test", + "state_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_with_invalid_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 invalid config and assert again + invalid_config = {"mqtt": "some_invalid_config"} + with patch( + "homeassistant.config.load_yaml_config_file", return_value=invalid_config + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test nothing changed as loading the config failed + assert hass.states.get("sensor.test") is not None From c287564e68013c4f84b94c7df052f4fbab99e8f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Sep 2023 16:34:25 -0500 Subject: [PATCH 895/984] Fix HomeKit handling of unavailable state (#101021) --- .../components/homekit/accessories.py | 4 ++- tests/components/homekit/test_type_covers.py | 6 +++-- tests/components/homekit/test_type_locks.py | 25 ++++++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 88422b5c957..5a1e9bc1ea2 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -465,7 +465,9 @@ class HomeAccessory(Accessory): # type: ignore[misc] def async_update_state_callback(self, new_state: State | None) -> None: """Handle state change listener callback.""" _LOGGER.debug("New_state: %s", new_state) - if new_state is None: + # HomeKit handles unavailable state via the available property + # so we should not propagate it here + if new_state is None or new_state.state == STATE_UNAVAILABLE: return battery_state = None battery_charging_state = None diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 9da576b6a0e..b8841289611 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -74,17 +74,19 @@ async def test_garage_door_open_close(hass: HomeAssistant, hk_driver, events) -> assert acc.char_obstruction_detected.value is True hass.states.async_set( - entity_id, STATE_UNAVAILABLE, {ATTR_OBSTRUCTION_DETECTED: False} + entity_id, STATE_UNAVAILABLE, {ATTR_OBSTRUCTION_DETECTED: True} ) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN - assert acc.char_obstruction_detected.value is False + assert acc.char_obstruction_detected.value is True + assert acc.available is False hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN + assert acc.available is True # Set from HomeKit call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 32f1561644e..dc614ee54c4 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, STATE_LOCKED, + STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, ) @@ -68,10 +69,32 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 - hass.states.async_remove(entity_id) + # Unavailable should keep last state + # but set the accessory to not available + hass.states.async_set(entity_id, STATE_UNAVAILABLE) await hass.async_block_till_done() assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 + assert acc.available is False + + hass.states.async_set(entity_id, STATE_UNLOCKED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert acc.available is True + + # Unavailable should keep last state + # but set the accessory to not available + hass.states.async_set(entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert acc.available is False + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 # Set from HomeKit call_lock = async_mock_service(hass, DOMAIN, "lock") From be93793db9f5b9c8d7a95994f39d54e6f5f4d927 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 28 Sep 2023 00:58:30 +0200 Subject: [PATCH 896/984] Update pyweatherflowudp to 1.4.3 (#101022) --- homeassistant/components/weatherflow/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow/manifest.json b/homeassistant/components/weatherflow/manifest.json index e2671d74cda..3c34250652d 100644 --- a/homeassistant/components/weatherflow/manifest.json +++ b/homeassistant/components/weatherflow/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyweatherflowudp"], - "requirements": ["pyweatherflowudp==1.4.2"] + "requirements": ["pyweatherflowudp==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index d9dc9f6439b..5fcf0dc6fd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2249,7 +2249,7 @@ pyvolumio==0.1.5 pywaze==0.5.0 # homeassistant.components.weatherflow -pyweatherflowudp==1.4.2 +pyweatherflowudp==1.4.3 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76b6ca777ac..75446892ece 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1675,7 +1675,7 @@ pyvolumio==0.1.5 pywaze==0.5.0 # homeassistant.components.weatherflow -pyweatherflowudp==1.4.2 +pyweatherflowudp==1.4.3 # homeassistant.components.html5 pywebpush==1.9.2 From af37de46bd46102b5eb089e4474f406133563e9b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 27 Sep 2023 19:55:26 -0500 Subject: [PATCH 897/984] Use webrtc-noise-gain without AVX2 (#101028) --- homeassistant/components/assist_pipeline/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/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 138f880526d..db6c517a81a 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["webrtc-noise-gain==1.2.1"] + "requirements": ["webrtc-noise-gain==1.2.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 698960095ba..bf287f564cc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 -webrtc-noise-gain==1.2.1 +webrtc-noise-gain==1.2.2 yarl==1.9.2 zeroconf==0.115.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5fcf0dc6fd6..098e57ea5ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2700,7 +2700,7 @@ waterfurnace==1.1.0 webexteamssdk==1.1.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.2.1 +webrtc-noise-gain==1.2.2 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75446892ece..bd708f0c767 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,7 +2006,7 @@ wallbox==0.4.12 watchdog==2.3.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.2.1 +webrtc-noise-gain==1.2.2 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 From b02f64196b59a5c061ceeb047b7dd87e90310fb9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 27 Sep 2023 21:00:57 -0400 Subject: [PATCH 898/984] Bumped version to 2023.10.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 0865e105f7a..0e659a58980 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 = 10 -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 c620af3200f..8ed01169f89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b0" +version = "2023.10.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9ab340047d3024f19d91dd675de30c24d4274133 Mon Sep 17 00:00:00 2001 From: lennart24 <18117505+lennart24@users.noreply.github.com> Date: Thu, 28 Sep 2023 12:59:02 +0200 Subject: [PATCH 899/984] Add shutter_tilt support for Fibaro FGR 223 (#96283) * add support for shutter_tilt for Fibaro FGR 223 add tests for fgr 223 * Adjust comments and docstring --------- Co-authored-by: Lennart <18117505+Ced4@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 60 + tests/components/zwave_js/conftest.py | 14 + .../fixtures/cover_fibaro_fgr223_state.json | 2325 +++++++++++++++++ tests/components/zwave_js/test_cover.py | 138 +- 4 files changed, 2530 insertions(+), 7 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index d54dc659be1..0a3f61fd824 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -160,6 +160,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): writeable: bool | None = None # [optional] the value's states map must include ANY of these key/value pairs any_available_states: set[tuple[int, str]] | None = None + # [optional] the value's value must match this value + value: Any | None = None @dataclass @@ -378,6 +380,61 @@ DISCOVERY_SCHEMAS = [ ) ], ), + # Fibaro Shutter Fibaro FGR223 + # Combine both switch_multilevel endpoints into shutter_tilt + # if operating mode (151) is set to venetian blind (2) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter_tilt", + manufacturer_id={0x010F}, + product_id={0x1000, 0x1001}, + product_type={0x0303}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={1}, + type={ValueType.NUMBER}, + ), + data_template=CoverTiltDataTemplate( + current_tilt_value_id=ZwaveValueID( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + target_tilt_value_id=ZwaveValueID( + property_=TARGET_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={CommandClass.CONFIGURATION}, + property={151}, + endpoint={0}, + value={2}, + ) + ], + ), + # Fibaro Shutter Fibaro FGR223 + # Disable endpoint 2 (slat), + # as these are either combined with endpoint one as shutter_tilt + # or it has no practical function. + # CC: Switch_Multilevel + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter", + manufacturer_id={0x010F}, + product_id={0x1000, 0x1001}, + product_type={0x0303}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), # Fibaro Nice BiDi-ZWave (IBT4ZWAVE) ZWaveDiscoverySchema( platform=Platform.COVER, @@ -1236,6 +1293,9 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: ) ): return False + # check value + if schema.value is not None and value.value not in schema.value: + return False return True diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index e950ff0402c..bbc836488c2 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -483,6 +483,12 @@ def fibaro_fgr222_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_fibaro_fgr222_state.json")) +@pytest.fixture(name="fibaro_fgr223_shutter_state", scope="session") +def fibaro_fgr223_shutter_state_fixture(): + """Load the Fibaro FGR223 node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_fibaro_fgr223_state.json")) + + @pytest.fixture(name="merten_507801_state", scope="session") def merten_507801_state_fixture(): """Load the Merten 507801 Shutter node state fixture data.""" @@ -1054,6 +1060,14 @@ def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): return node +@pytest.fixture(name="fibaro_fgr223_shutter") +def fibaro_fgr223_shutter_cover_fixture(client, fibaro_fgr223_shutter_state): + """Mock a Fibaro FGR223 Shutter node.""" + node = Node(client, copy.deepcopy(fibaro_fgr223_shutter_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="merten_507801") def merten_507801_cover_fixture(client, merten_507801_state): """Mock a Merten 507801 Shutter node.""" diff --git a/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json b/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json new file mode 100644 index 00000000000..b0f4992e319 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json @@ -0,0 +1,2325 @@ +{ + "nodeId": 10, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 271, + "productId": 4096, + "productType": 771, + "firmwareVersion": "5.1", + "zwavePlusVersion": 1, + "name": "fgr 223 test cover", + "location": "test location", + "deviceConfig": { + "filename": "/data/db/devices/0x010f/fgr223.json", + "isEmbedded": true, + "manufacturer": "Fibargroup", + "manufacturerId": 271, + "label": "FGR223", + "description": "Roller Shutter 3", + "devices": [ + { + "productType": 771, + "productId": 4096 + }, + { + "productType": 771, + "productId": 12288 + }, + { + "productType": 771, + "productId": 16384 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "proprietary": { + "fibaroCCs": [38] + } + }, + "label": "FGR223", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 2, + "aggregatedEndpointCount": 0, + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 10, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 86, + "name": "CRC-16 Encapsulation", + "version": 1, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": true + } + ] + }, + { + "nodeId": 10, + "index": 1, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 10, + "index": 2, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 108, + "commandClassName": "Supervision", + "property": "ccSupported", + "propertyKey": 91, + "propertyName": "ccSupported", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Switch type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Switch type", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "Momentary switches", + "1": "Toggle switches", + "2": "Single momentary switch (S1)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Switch type" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Inputs orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inputs orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "default", + "1": "reversed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Inputs orientation" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Outputs orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Outputs orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "default", + "1": "reversed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Outputs orientation" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 1, + "propertyName": "S1 scenes: Pressed 1 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 1 time", + "label": "S1 scenes: Pressed 1 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 1 time", + "info": "Send a Central Scene notification when S1 is pressed 1 time" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 2, + "propertyName": "S1 scenes: Pressed 2 times", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 2 times", + "label": "S1 scenes: Pressed 2 times", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 2 times", + "info": "Send a Central Scene notification when S1 is pressed 2 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 4, + "propertyName": "S1 scenes: Pressed 3 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 3 times", + "label": "S1 scenes: Pressed 3 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 3 time", + "info": "Send a Central Scene notification when S1 is pressed 3 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 8, + "propertyName": "S1 scenes: Hold down / Release", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is held down or released", + "label": "S1 scenes: Hold down / Release", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Hold down / Release", + "info": "Send a Central Scene notification when S1 is held down or released" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 1, + "propertyName": "S2 scenes: Pressed 1 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 1 time", + "label": "S2 scenes: Pressed 1 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 1 time", + "info": "Send a Central Scene notification when S2 is pressed 1 time" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 2, + "propertyName": "S2 scenes: Pressed 2 times", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 2 times", + "label": "S2 scenes: Pressed 2 times", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 2 times", + "info": "Send a Central Scene notification when S2 is pressed 2 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 4, + "propertyName": "S2 scenes: Pressed 3 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 3 times", + "label": "S2 scenes: Pressed 3 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 3 time", + "info": "Send a Central Scene notification when S2 is pressed 3 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 8, + "propertyName": "S2 scenes: Hold down / Release", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is held down or released", + "label": "S2 scenes: Hold down / Release", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Hold down / Release", + "info": "Send a Central Scene notification when S2 is held down or released" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 60, + "propertyName": "Measuring power consumed by the device itself", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Measuring power consumed by the device itself", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Function inactive", + "1": "Function active" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Measuring power consumed by the device itself" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 61, + "propertyName": "Power reports - on change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power reports - on change", + "default": 15, + "min": 0, + "max": 500, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Power reports - on change" + }, + "value": 15 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 62, + "propertyName": "Power reports - periodic", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power reports - periodic", + "default": 3600, + "min": 0, + "max": 32400, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Power reports - periodic" + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 65, + "propertyName": "Energy reports - on change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Energy reports - on change", + "default": 10, + "min": 0, + "max": 500, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Energy reports - on change" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 66, + "propertyName": "Energy reports - periodic", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Energy reports - periodic", + "default": 3600, + "min": 0, + "max": 32400, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Energy reports - periodic" + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 150, + "propertyName": "Force calibration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Force calibration", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "device is not calibrated", + "1": "device is calibrated", + "2": "force device calibration" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Force calibration" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 151, + "propertyName": "Operating mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Operating mode", + "default": 1, + "min": 1, + "max": 6, + "states": { + "1": "roller blind", + "2": "Venetian blind", + "3": "gate w/o positioning", + "4": "gate with positioning", + "5": "roller blind with built-in driver", + "6": "roller blind with built-in driver (impulse)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Operating mode" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 152, + "propertyName": "Venetian blind - time of full turn of the slats", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Venetian blind - time of full turn of the slats", + "default": 150, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Venetian blind - time of full turn of the slats" + }, + "value": 150 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 153, + "propertyName": "Set slats back to previous position", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Set slats back to previous position", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Main controller operation", + "1": "Controller, Momentary Switch, Limit Switch", + "2": "Controller, both Switches, Multilevel Stop" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Set slats back to previous position" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 154, + "propertyName": "Delay motor stop", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay motor stop after reaching end switch", + "label": "Delay motor stop", + "default": 10, + "min": 0, + "max": 255, + "unit": "1/10 sec", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Delay motor stop", + "info": "Delay motor stop after reaching end switch" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 155, + "propertyName": "Motor operation detection", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Power threshold to be interpreted as reaching a limit switch", + "label": "Motor operation detection", + "default": 10, + "min": 0, + "max": 255, + "unit": "W", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Motor operation detection", + "info": "Power threshold to be interpreted as reaching a limit switch" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 156, + "propertyName": "Time of up movement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Time of up movement", + "default": 6000, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Time of up movement" + }, + "value": 5000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 157, + "propertyName": "Time of down movement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Time of down movement", + "default": 6000, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Time of down movement" + }, + "value": 5000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "Alarm #1: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #1 is triggered", + "label": "Alarm #1: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #1: Action", + "info": "Which action to perform when Alarm #1 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Alarm #1: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #1 should be limited to", + "label": "Alarm #1: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Event/State Parameters", + "info": "Which event parameters Alarm #1 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Alarm #1: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #1 should be limited to", + "label": "Alarm #1: Notification Status", + "default": 0, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Notification Status", + "info": "Which notification status Alarm #1 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Alarm #1: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #1", + "label": "Alarm #1: Notification Type", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Notification Type", + "info": "Which notification type should raise Alarm #1" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "Alarm #2: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #2 is triggered", + "label": "Alarm #2: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #2: Action", + "info": "Which action to perform when Alarm #2 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Alarm #2: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #2 should be limited to", + "label": "Alarm #2: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Event/State Parameters", + "info": "Which event parameters Alarm #2 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Alarm #2: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #2 should be limited to", + "label": "Alarm #2: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Notification Status", + "info": "Which notification status Alarm #2 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Alarm #2: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #2", + "label": "Alarm #2: Notification Type", + "default": 5, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Notification Type", + "info": "Which notification type should raise Alarm #2" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 255, + "propertyName": "Alarm #3: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #3 is triggered", + "label": "Alarm #3: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #3: Action", + "info": "Which action to perform when Alarm #3 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 65280, + "propertyName": "Alarm #3: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #3 should be limited to", + "label": "Alarm #3: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Event/State Parameters", + "info": "Which event parameters Alarm #3 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 16711680, + "propertyName": "Alarm #3: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #3 should be limited to", + "label": "Alarm #3: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Notification Status", + "info": "Which notification status Alarm #3 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 4278190080, + "propertyName": "Alarm #3: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #3", + "label": "Alarm #3: Notification Type", + "default": 1, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Notification Type", + "info": "Which notification type should raise Alarm #3" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 255, + "propertyName": "Alarm #4: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #4 is triggered", + "label": "Alarm #4: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #4: Action", + "info": "Which action to perform when Alarm #4 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 65280, + "propertyName": "Alarm #4: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #4 should be limited to", + "label": "Alarm #4: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Event/State Parameters", + "info": "Which event parameters Alarm #4 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 16711680, + "propertyName": "Alarm #4: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #4 should be limited to", + "label": "Alarm #4: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Notification Status", + "info": "Which notification status Alarm #4 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 4278190080, + "propertyName": "Alarm #4: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #4", + "label": "Alarm #4: Notification Type", + "default": 2, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Notification Type", + "info": "Which notification type should raise Alarm #4" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 255, + "propertyName": "Alarm #5: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #5 is triggered", + "label": "Alarm #5: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #5: Action", + "info": "Which action to perform when Alarm #5 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Alarm #5: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #5 should be limited to", + "label": "Alarm #5: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Event/State Parameters", + "info": "Which event parameters Alarm #5 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Alarm #5: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #5 should be limited to", + "label": "Alarm #5: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Notification Status", + "info": "Which notification status Alarm #5 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Alarm #5: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #5", + "label": "Alarm #5: Notification Type", + "default": 4, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Notification Type", + "info": "Which notification type should raise Alarm #5" + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 271 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 771 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 4096 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Node ID with exclusive control", + "min": 1, + "max": 232, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection timeout", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "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": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.2" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["5.1", "5.1"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 1, + "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": "unknown" + }, + { + "endpoint": 1, + "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": 99 + }, + { + "endpoint": 1, + "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": 99 + }, + { + "endpoint": 1, + "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"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 1, + "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"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0.0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "System hardware failure (with failure code)" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "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": "unknown" + }, + { + "endpoint": 2, + "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": 2, + "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": 2, + "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"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 2, + "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"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x010f:0x0303:0x1000:5.1", + "statistics": { + "commandsTX": 8, + "commandsRX": 13, + "commandsDroppedRX": 12, + "commandsDroppedTX": 0, + "timeoutResponse": 1, + "rtt": 155.4, + "rssi": -66, + "lwr": { + "protocolDataRate": 2, + "repeaters": [11], + "rssi": -56, + "repeaterRSSI": [-55] + }, + "nlwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -89, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index e51b3751ac8..fc593de883b 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -47,7 +47,8 @@ GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" -FIBARO_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +FIBARO_FGR_222_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +FIBARO_FGR_223_SHUTTER_COVER_ENTITY = "cover.fgr_223_test_cover" LOGGER.setLevel(logging.DEBUG) @@ -238,7 +239,7 @@ async def test_fibaro_fgr222_shutter_cover( hass: HomeAssistant, client, fibaro_fgr222_shutter, integration ) -> None: """Test tilt function of the Fibaro Shutter devices.""" - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER @@ -249,7 +250,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -271,7 +272,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -293,7 +294,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, blocking=True, ) @@ -330,7 +331,101 @@ async def test_fibaro_fgr222_shutter_cover( }, ) fibaro_fgr222_shutter.receive_event(event) - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + +async def test_fibaro_fgr223_shutter_cover( + hass: HomeAssistant, client, fibaro_fgr223_shutter, integration +) -> None: + """Test tilt function of the Fibaro Shutter devices.""" + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER + + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Test opening tilts + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + 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"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 99 + + client.async_send_command.reset_mock() + # Test closing tilts + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + 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"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + # Test setting tilt position + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + blocking=True, + ) + + 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"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 12 + + # Test some tilt + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 10, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 2, + "property": "currentValue", + "newValue": 99, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + fibaro_fgr223_shutter.receive_event(event) + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) assert state assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 @@ -694,13 +789,42 @@ async def test_fibaro_fgr222_shutter_cover_no_tilt( client.driver.controller.emit("node added", {"node": node}) await hass.async_block_till_done() - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) assert state assert state.state == STATE_UNKNOWN assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes +async def test_fibaro_fgr223_shutter_cover_no_tilt( + hass: HomeAssistant, client, fibaro_fgr223_shutter_state, integration +) -> None: + """Test absence of tilt function for Fibaro Shutter roller blind. + + Fibaro Shutter devices can have operating mode set to roller blind (1). + """ + node_state = replace_value_of_zwave_value( + fibaro_fgr223_shutter_state, + [ + ZwaveValueMatcher( + property_=151, + command_class=CommandClass.CONFIGURATION, + endpoint=0, + ), + ], + 1, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) + assert state + assert state.state == STATE_OPEN + assert ATTR_CURRENT_POSITION in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + async def test_iblinds_v3_cover( hass: HomeAssistant, client, iblinds_v3, integration ) -> None: From 81e8ca130f0644386e5467c8038af883b37f7bc9 Mon Sep 17 00:00:00 2001 From: tyjtyj Date: Thu, 28 Sep 2023 14:08:07 +0800 Subject: [PATCH 900/984] Fix google maps device_tracker same last seen timestamp (#99963) * Update device_tracker.py This fix the google_maps does not show current location when HA started/restarted and also fix unnecessary update when last_seen timestamp is the same. Unnecessary update is causing proximity sensor switching from between stationary and certain direction. * Remove elif * Fix Black check * fix black check * Update device_tracker.py Better patch * Update device_tracker.py * Update device_tracker.py Fix Black * Update device_tracker.py change warning to debug --- .../components/google_maps/device_tracker.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 2ee12f0154c..be776df1751 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -112,12 +112,22 @@ class GoogleMapsScanner: last_seen = dt_util.as_utc(person.datetime) if last_seen < self._prev_seen.get(dev_id, last_seen): - _LOGGER.warning( + _LOGGER.debug( "Ignoring %s update because timestamp is older than last timestamp", person.nickname, ) _LOGGER.debug("%s < %s", last_seen, self._prev_seen[dev_id]) continue + if last_seen == self._prev_seen.get(dev_id, last_seen) and hasattr( + self, "success_init" + ): + _LOGGER.debug( + "Ignoring %s update because timestamp " + "is the same as the last timestamp %s", + person.nickname, + last_seen, + ) + continue self._prev_seen[dev_id] = last_seen attrs = { From 35eaebd1825e16643d0a4b8cdbdcc2d0275cebd6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 10:55:48 +0200 Subject: [PATCH 901/984] Add feature to add measuring station via number in waqi (#99992) * Add feature to add measuring station via number * Add feature to add measuring station via number * Add feature to add measuring station via number --- homeassistant/components/waqi/config_flow.py | 123 ++++++++++-- homeassistant/components/waqi/strings.json | 22 ++- tests/components/waqi/test_config_flow.py | 191 +++++++++++++++++-- 3 files changed, 301 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index b5f3a18b223..8404b425678 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -1,6 +1,7 @@ """Config flow for World Air Quality Index (WAQI) integration.""" from __future__ import annotations +from collections.abc import Awaitable, Callable import logging from typing import Any @@ -18,25 +19,36 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, + CONF_METHOD, CONF_NAME, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.selector import LocationSelector +from homeassistant.helpers.selector import ( + LocationSelector, + SelectSelector, + SelectSelectorConfig, +) from homeassistant.helpers.typing import ConfigType from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER _LOGGER = logging.getLogger(__name__) +CONF_MAP = "map" + class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for World Air Quality Index (WAQI).""" VERSION = 1 + def __init__(self) -> None: + """Initialize config flow.""" + self.data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -47,13 +59,8 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): session=async_get_clientsession(self.hass) ) as waqi_client: waqi_client.authenticate(user_input[CONF_API_KEY]) - location = user_input[CONF_LOCATION] try: - measuring_station: WAQIAirQuality = ( - await waqi_client.get_by_coordinates( - location[CONF_LATITUDE], location[CONF_LONGITUDE] - ) - ) + await waqi_client.get_by_ip() except WAQIAuthenticationError: errors["base"] = "invalid_auth" except WAQIConnectionError: @@ -62,36 +69,110 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception(exc) errors["base"] = "unknown" else: - await self.async_set_unique_id(str(measuring_station.station_id)) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=measuring_station.city.name, - data={ - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_STATION_NUMBER: measuring_station.station_id, - }, - ) + self.data = user_input + if user_input[CONF_METHOD] == CONF_MAP: + return await self.async_step_map() + return await self.async_step_station_number() return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_METHOD): SelectSelector( + SelectSelectorConfig( + options=[CONF_MAP, CONF_STATION_NUMBER], + translation_key="method", + ) + ), + } + ), + errors=errors, + ) + + async def _async_base_step( + self, + step_id: str, + method: Callable[[WAQIClient, dict[str, Any]], Awaitable[WAQIAirQuality]], + data_schema: vol.Schema, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + errors: dict[str, str] = {} + if user_input is not None: + async with WAQIClient( + session=async_get_clientsession(self.hass) + ) as waqi_client: + waqi_client.authenticate(self.data[CONF_API_KEY]) + try: + measuring_station = await method(waqi_client, user_input) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) + return self.async_show_form( + step_id=step_id, data_schema=data_schema, errors=errors + ) + + async def async_step_map( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add measuring station via map.""" + return await self._async_base_step( + CONF_MAP, + lambda waqi_client, data: waqi_client.get_by_coordinates( + data[CONF_LOCATION][CONF_LATITUDE], data[CONF_LOCATION][CONF_LONGITUDE] + ), + self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_API_KEY): str, vol.Required( CONF_LOCATION, ): LocationSelector(), } ), - user_input - or { + { CONF_LOCATION: { CONF_LATITUDE: self.hass.config.latitude, CONF_LONGITUDE: self.hass.config.longitude, } }, ), - errors=errors, + user_input, + ) + + async def async_step_station_number( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add measuring station via station number.""" + return await self._async_base_step( + CONF_STATION_NUMBER, + lambda waqi_client, data: waqi_client.get_by_station_number( + data[CONF_STATION_NUMBER] + ), + vol.Schema( + { + vol.Required( + CONF_STATION_NUMBER, + ): int, + } + ), + user_input, + ) + + async def _async_create_entry( + self, measuring_station: WAQIAirQuality + ) -> FlowResult: + await self.async_set_unique_id(str(measuring_station.station_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=measuring_station.city.name, + data={ + CONF_API_KEY: self.data[CONF_API_KEY], + CONF_STATION_NUMBER: measuring_station.station_id, + }, ) async def async_step_import(self, import_config: ConfigType) -> FlowResult: diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index 4ceb911de9e..46031a3072b 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -2,10 +2,20 @@ "config": { "step": { "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "method": "How do you want to select a measuring station?" + } + }, + "map": { "description": "Select a location to get the closest measuring station.", "data": { - "location": "[%key:common::config_flow::data::location%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "location": "[%key:common::config_flow::data::location%]" + } + }, + "station_number": { + "data": { + "station_number": "Measuring station number" } } }, @@ -18,6 +28,14 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "selector": { + "method": { + "options": { + "map": "Select nearest from point on the map", + "station_number": "Enter a station number" + } + } + }, "issues": { "deprecated_yaml_import_issue_invalid_auth": { "title": "The World Air Quality Index YAML configuration import failed", diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index 3901ffad550..be738a119e5 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -1,17 +1,20 @@ """Test the World Air Quality Index (WAQI) config flow.""" import json +from typing import Any from unittest.mock import AsyncMock, patch from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError import pytest from homeassistant import config_entries +from homeassistant.components.waqi.config_flow import CONF_MAP from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, + CONF_METHOD, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,7 +24,29 @@ from tests.common import load_fixture pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +@pytest.mark.parametrize( + ("method", "payload"), + [ + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + ), + ], +) +async def test_full_map_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + method: str, + payload: dict[str, Any], +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -31,17 +56,36 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No with patch( "aiowaqi.WAQIClient.authenticate", ), patch( - "aiowaqi.WAQIClient.get_by_coordinates", + "aiowaqi.WAQIClient.get_by_ip", return_value=WAQIAirQuality.parse_obj( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", - }, + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == method + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, ) await hass.async_block_till_done() @@ -73,21 +117,35 @@ async def test_flow_errors( with patch( "aiowaqi.WAQIClient.authenticate", ), patch( - "aiowaqi.WAQIClient.get_by_coordinates", + "aiowaqi.WAQIClient.get_by_ip", side_effect=exception, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", - }, + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": error} + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_ip", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "map" + with patch( "aiowaqi.WAQIClient.authenticate", ), patch( @@ -100,9 +158,118 @@ async def test_flow_errors( result["flow_id"], { CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", }, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("method", "payload", "exception", "error"), + [ + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + WAQIConnectionError(), + "cannot_connect", + ), + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + Exception(), + "unknown", + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + WAQIConnectionError(), + "cannot_connect", + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + Exception(), + "unknown", + ), + ], +) +async def test_error_in_second_step( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + method: str, + payload: dict[str, Any], + exception: Exception, + error: str, +) -> 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 + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_ip", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == method + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception + ), patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "de Jongweg, Utrecht" + assert result["data"] == { + CONF_API_KEY: "asd", + CONF_STATION_NUMBER: 4584, + } + assert len(mock_setup_entry.mock_calls) == 1 From d6c42ee8e7ee20067501d2ef2f98a2f94a37bb31 Mon Sep 17 00:00:00 2001 From: Tereza Tomcova Date: Thu, 28 Sep 2023 14:15:22 +0300 Subject: [PATCH 902/984] Bump PySwitchbot to 0.40.0 to support Curtain 3 (#100619) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 49a6af2b179..e685d1de806 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.39.1"] + "requirements": ["PySwitchbot==0.40.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 098e57ea5ee..c4b3c65ebaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,7 +97,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.39.1 +PySwitchbot==0.40.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd708f0c767..82d5c52e6a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.39.1 +PySwitchbot==0.40.0 # homeassistant.components.syncthru PySyncThru==0.7.10 From 5bd306392ff58adea7e46cab66e44770c0200a8c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Sep 2023 17:45:10 +0200 Subject: [PATCH 903/984] Add LED control support to Home Assistant Green (#100922) * Add LED control support to Home Assistant Green * Add strings.json * Sort alphabetically * Reorder LED schema * Improve test coverage * Apply suggestions from code review Co-authored-by: Stefan Agner * Sort + fix test * Remove reboot menu --------- Co-authored-by: Stefan Agner --- homeassistant/components/hassio/__init__.py | 2 + homeassistant/components/hassio/handler.py | 21 +++ .../homeassistant_green/config_flow.py | 80 ++++++++- .../homeassistant_green/strings.json | 28 +++ tests/components/hassio/test_handler.py | 42 +++++ .../homeassistant_green/test_config_flow.py | 164 ++++++++++++++++++ 6 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homeassistant_green/strings.json diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 3303059d824..75b2535bd44 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -88,11 +88,13 @@ from .handler import ( # noqa: F401 async_get_addon_discovery_info, async_get_addon_info, async_get_addon_store_info, + async_get_green_settings, async_get_yellow_settings, async_install_addon, async_reboot_host, async_restart_addon, async_set_addon_options, + async_set_green_settings, async_set_yellow_settings, async_start_addon, async_stop_addon, diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 020a4365ec6..fe9e1ba1d2e 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -263,6 +263,27 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b return await hassio.send_command(command, timeout=None) +@api_data +async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: + """Return settings specific to Home Assistant Green.""" + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/os/boards/green", method="get") + + +@api_data +async def async_set_green_settings( + hass: HomeAssistant, settings: dict[str, bool] +) -> dict: + """Set settings specific to Home Assistant Green. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command( + "/os/boards/green", method="post", payload=settings + ) + + @api_data async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Yellow.""" diff --git a/homeassistant/components/homeassistant_green/config_flow.py b/homeassistant/components/homeassistant_green/config_flow.py index 17ba9aacbc5..c3491de430e 100644 --- a/homeassistant/components/homeassistant_green/config_flow.py +++ b/homeassistant/components/homeassistant_green/config_flow.py @@ -1,22 +1,100 @@ """Config flow for the Home Assistant Green integration.""" from __future__ import annotations +import asyncio +import logging from typing import Any -from homeassistant.config_entries import ConfigFlow +import aiohttp +import voluptuous as vol + +from homeassistant.components.hassio import ( + HassioAPIError, + async_get_green_settings, + async_set_green_settings, + is_hassio, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + +STEP_HW_SETTINGS_SCHEMA = vol.Schema( + { + # Sorted to match front panel left to right + vol.Required("power_led"): selector.BooleanSelector(), + vol.Required("activity_led"): selector.BooleanSelector(), + vol.Required("system_health_led"): selector.BooleanSelector(), + } +) + class HomeAssistantGreenConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Green.""" VERSION = 1 + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> HomeAssistantGreenOptionsFlow: + """Return the options flow.""" + return HomeAssistantGreenOptionsFlow() + async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="Home Assistant Green", data={}) + + +class HomeAssistantGreenOptionsFlow(OptionsFlow): + """Handle an option flow for Home Assistant Green.""" + + _hw_settings: dict[str, bool] | None = None + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if not is_hassio(self.hass): + return self.async_abort(reason="not_hassio") + + return await self.async_step_hardware_settings() + + async def async_step_hardware_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle hardware settings.""" + + if user_input is not None: + if self._hw_settings == user_input: + return self.async_create_entry(data={}) + try: + async with asyncio.timeout(10): + await async_set_green_settings(self.hass, user_input) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to write hardware settings", exc_info=err) + return self.async_abort(reason="write_hw_settings_error") + return self.async_create_entry(data={}) + + try: + async with asyncio.timeout(10): + self._hw_settings: dict[str, bool] = await async_get_green_settings( + self.hass + ) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to read hardware settings", exc_info=err) + return self.async_abort(reason="read_hw_settings_error") + + schema = self.add_suggested_values_to_schema( + STEP_HW_SETTINGS_SCHEMA, self._hw_settings + ) + + return self.async_show_form(step_id="hardware_settings", data_schema=schema) diff --git a/homeassistant/components/homeassistant_green/strings.json b/homeassistant/components/homeassistant_green/strings.json new file mode 100644 index 00000000000..9066ca64e5c --- /dev/null +++ b/homeassistant/components/homeassistant_green/strings.json @@ -0,0 +1,28 @@ +{ + "options": { + "step": { + "hardware_settings": { + "title": "Configure hardware settings", + "data": { + "activity_led": "Green: activity LED", + "power_led": "White: power LED", + "system_health_led": "Yellow: system health LED" + } + }, + "reboot_menu": { + "title": "Reboot required", + "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", + "menu_options": { + "reboot_later": "Reboot manually later", + "reboot_now": "Reboot now" + } + } + }, + "abort": { + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "read_hw_settings_error": "Failed to read hardware settings", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "write_hw_settings_error": "Failed to write hardware settings" + } + } +} diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index d92a5335809..06c726360d9 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -364,6 +364,48 @@ async def test_api_headers( assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream" +async def test_api_get_green_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/os/boards/green", + json={ + "result": "ok", + "data": { + "activity_led": True, + "power_led": True, + "system_health_led": True, + }, + }, + ) + + assert await handler.async_get_green_settings(hass) == { + "activity_led": True, + "power_led": True, + "system_health_led": True, + } + assert aioclient_mock.call_count == 1 + + +async def test_api_set_green_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.post( + "http://127.0.0.1/os/boards/green", + json={"result": "ok", "data": {}}, + ) + + assert ( + await handler.async_set_green_settings( + hass, {"activity_led": True, "power_led": True, "system_health_led": True} + ) + == {} + ) + assert aioclient_mock.call_count == 1 + + async def test_api_get_yellow_settings( hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/homeassistant_green/test_config_flow.py b/tests/components/homeassistant_green/test_config_flow.py index 2eb7389af55..84af22509f9 100644 --- a/tests/components/homeassistant_green/test_config_flow.py +++ b/tests/components/homeassistant_green/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Home Assistant Green config flow.""" from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_green.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -8,6 +10,29 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(name="get_green_settings") +def mock_get_green_settings(): + """Mock getting green settings.""" + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_get_green_settings", + return_value={ + "activity_led": True, + "power_led": True, + "system_health_led": True, + }, + ) as get_green_settings: + yield get_green_settings + + +@pytest.fixture(name="set_green_settings") +def mock_set_green_settings(): + """Mock setting green settings.""" + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_set_green_settings", + ) as set_green_settings: + yield set_green_settings + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) @@ -56,3 +81,142 @@ async def test_config_flow_single_entry(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" mock_setup_entry.assert_not_called() + + +async def test_option_flow_non_hassio( + hass: HomeAssistant, +) -> None: + """Test installing the multi pan addon on a Core installation, without hassio.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.config_flow.is_hassio", + return_value=False, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_hassio" + + +async def test_option_flow_led_settings( + hass: HomeAssistant, + get_green_settings, + set_green_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": False, "power_led": False, "system_health_led": False}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_green_settings.assert_called_once_with( + hass, {"activity_led": False, "power_led": False, "system_health_led": False} + ) + + +async def test_option_flow_led_settings_unchanged( + hass: HomeAssistant, + get_green_settings, + set_green_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": True, "power_led": True, "system_health_led": True}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_green_settings.assert_not_called() + + +async def test_option_flow_led_settings_fail_1(hass: HomeAssistant) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_get_green_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "read_hw_settings_error" + + +async def test_option_flow_led_settings_fail_2( + hass: HomeAssistant, get_green_settings +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_set_green_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": False, "power_led": False, "system_health_led": False}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "write_hw_settings_error" From ffad30734ba5d61fdcdc6eb53083d192b9fd4ad5 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 28 Sep 2023 16:39:57 +1300 Subject: [PATCH 904/984] ESPHome: dont send error when wake word is aborted (#101032) * ESPHome dont send error when wake word is aborted * Add test --- .../components/esphome/voice_assistant.py | 8 +++-- .../esphome/test_voice_assistant.py | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 58f9ce5abf4..baf3a9011e9 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -24,7 +24,10 @@ from homeassistant.components.assist_pipeline import ( async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.error import WakeWordDetectionError +from homeassistant.components.assist_pipeline.error import ( + WakeWordDetectionAborted, + WakeWordDetectionError, +) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -273,6 +276,8 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): }, ) _LOGGER.warning("Pipeline not found") + except WakeWordDetectionAborted: + pass # Wake word detection was aborted and `handle_finished` is enough. except WakeWordDetectionError as e: self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, @@ -281,7 +286,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): "message": e.message, }, ) - _LOGGER.warning("No Wake word provider found") finally: self.handle_finished() diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index 6c54c5f62f3..9b6bcf1c6c7 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -12,7 +12,10 @@ from homeassistant.components.assist_pipeline import ( PipelineEventType, PipelineStage, ) -from homeassistant.components.assist_pipeline.error import WakeWordDetectionError +from homeassistant.components.assist_pipeline.error import ( + WakeWordDetectionAborted, + WakeWordDetectionError, +) from homeassistant.components.esphome import DomainData from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer from homeassistant.core import HomeAssistant @@ -411,3 +414,27 @@ async def test_wake_word_exception( conversation_id=None, flags=2, ) + + +async def test_wake_word_abort_exception( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test that the pipeline is set to start with Wake word.""" + + async def async_pipeline_from_audio_stream(*args, **kwargs): + raise WakeWordDetectionAborted + + with patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch.object(voice_assistant_udp_server_v2, "handle_event") as mock_handle_event: + voice_assistant_udp_server_v2.transport = Mock() + + await voice_assistant_udp_server_v2.run_pipeline( + device_id="mock-device-id", + conversation_id=None, + flags=2, + ) + + mock_handle_event.assert_not_called() From 0147108b89b6a45f1d3887239a5688b3f05cfad3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 09:13:33 +0200 Subject: [PATCH 905/984] Fix onvif creating a new entity for every new event (#101035) Use topic value as topic --- homeassistant/components/onvif/parsers.py | 224 ++++++++++++---------- 1 file changed, 125 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 3f405767c54..6185adb70a1 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -48,15 +48,16 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/MotionAlarm """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -71,15 +72,16 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBlurry/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -95,15 +97,16 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooDark/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -119,15 +122,16 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBright/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -143,15 +147,16 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/GlobalSceneChange/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -167,8 +172,9 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: audio_source = "" audio_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -177,12 +183,12 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{audio_source}_{audio_analytics}_{rule}", + f"{uid}_{topic_value}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -198,8 +204,9 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -208,12 +215,12 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = source.Value evt = Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -230,8 +237,9 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -240,12 +248,12 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -261,8 +269,9 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -271,12 +280,12 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value in ["1", "true"], + message_value.Data.SimpleItem[0].Value in ["1", "true"], ) except (AttributeError, KeyError): return None @@ -292,8 +301,9 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -302,12 +312,12 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -322,18 +332,19 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -347,18 +358,19 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -372,18 +384,19 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -397,18 +410,19 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -422,18 +436,19 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -446,15 +461,16 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/DigitalInput """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Digital Input", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -467,15 +483,16 @@ async def async_parse_relay(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/Relay """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Relay Triggered", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "active", + message_value.Data.SimpleItem[0].Value == "active", ) except (AttributeError, KeyError): return None @@ -488,15 +505,16 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: Topic: tns1:Device/HardwareFailure/StorageFailure """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Storage Failure", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -510,13 +528,14 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/ProcessorUsage """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - usage = float(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + usage = float(message_value.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Processor Usage", "sensor", None, @@ -535,10 +554,11 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Last Reboot", "sensor", "timestamp", @@ -557,10 +577,11 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Last Reset", "sensor", "timestamp", @@ -581,10 +602,11 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Last Backup", "sensor", "timestamp", @@ -604,10 +626,11 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Last Clock Synchronization", "sensor", "timestamp", @@ -628,15 +651,16 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Recording Job State", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "Active", + message_value.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -653,8 +677,9 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -663,12 +688,12 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - value_1.Data.SimpleItem[0].Value, + message_value.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -685,8 +710,9 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -695,12 +721,12 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - value_1.Data.SimpleItem[0].Value, + message_value.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): From f13059eaf58e4f6a3caee41fa4f906a5bec48a1d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 19:06:45 +0200 Subject: [PATCH 906/984] Pin pydantic to 1.10.12 (#101044) --- homeassistant/package_constraints.txt | 5 +++-- script/gen_requirements_all.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bf287f564cc..678195986e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -126,8 +126,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Require to avoid issues with decorators (#93904). v2 has breaking changes. -pydantic>=1.10.8,<2.0 +# Required to avoid breaking (#101042). +# v2 has breaking changes (#99218). +pydantic==1.10.12 # Breaks asyncio # https://github.com/pubnub/python/issues/130 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e0e00ebc958..a8bc99d68fa 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -128,8 +128,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Require to avoid issues with decorators (#93904). v2 has breaking changes. -pydantic>=1.10.8,<2.0 +# Required to avoid breaking (#101042). +# v2 has breaking changes (#99218). +pydantic==1.10.12 # Breaks asyncio # https://github.com/pubnub/python/issues/130 From 081f194f6adb506368bf867ccb454b565b66130a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 28 Sep 2023 16:52:16 +0200 Subject: [PATCH 907/984] Update aioairzone-cloud to v0.2.3 (#101052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 31 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 63d9d3fffaa..1a158fcd1fe 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.2.2"] + "requirements": ["aioairzone-cloud==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index c4b3c65ebaa..2c239ba27cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.2 +aioairzone-cloud==0.2.3 # homeassistant.components.airzone aioairzone==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82d5c52e6a6..53e0884fed9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.2 +aioairzone-cloud==0.2.3 # homeassistant.components.airzone aioairzone==0.6.8 diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index fb33323378a..44bd0e45e2a 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -113,6 +113,7 @@ 'active': True, 'available': True, 'humidity': 27, + 'id': 'group1', 'installation': 'installation1', 'mode': 2, 'modes': list([ @@ -144,6 +145,7 @@ 'aidoo1', ]), 'available': True, + 'id': 'grp2', 'installation': 'installation1', 'mode': 3, 'modes': list([ @@ -165,12 +167,41 @@ }), 'installations': dict({ 'installation1': dict({ + 'action': 1, + 'active': True, + 'aidoos': list([ + 'aidoo1', + ]), + 'available': True, + 'humidity': 27, 'id': 'installation1', + 'mode': 2, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'House', + 'num-devices': 3, + 'power': True, + 'systems': list([ + 'system1', + ]), + 'temperature': 22.0, + 'temperature-setpoint': 23.3, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-step': 0.5, 'web-servers': list([ 'webserver1', '11:22:33:44:55:67', ]), + 'zones': list([ + 'zone1', + 'zone2', + ]), }), }), 'systems': dict({ From ad8033c0f267d1d1b1c7b9435aa32248d5e5cd42 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 19:08:26 +0200 Subject: [PATCH 908/984] Don't show withings repair if it's not in YAML (#101054) --- homeassistant/components/withings/__init__.py | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 7b6a56995c8..44d32b0603c 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -76,29 +76,30 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Withings component.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Withings", - }, - ) - if CONF_CLIENT_ID in config: - await async_import_client_credential( + if conf := config.get(DOMAIN): + async_create_issue( hass, - DOMAIN, - ClientCredential( - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], - ), + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Withings", + }, ) + if CONF_CLIENT_ID in conf: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + ), + ) return True From 1bbd4662b71a3b010b05bb19ed5a3b7449c7ecd8 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 28 Sep 2023 10:07:22 -0700 Subject: [PATCH 909/984] Bump apple_weatherkit to 1.0.4 (#101057) --- 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 34a5d45ca1f..d28a6ff3315 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.3"] + "requirements": ["apple_weatherkit==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2c239ba27cb..c71a358fbd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -423,7 +423,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.3 +apple_weatherkit==1.0.4 # homeassistant.components.apprise apprise==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53e0884fed9..1006ee60b0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -389,7 +389,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.3 +apple_weatherkit==1.0.4 # homeassistant.components.apprise apprise==1.5.0 From 17362e19543a542a775e2ec7d22ae4e2c26a9b0c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 28 Sep 2023 12:07:00 -0500 Subject: [PATCH 910/984] Remove fma instructions from webrtc-noise-gain (#101060) --- homeassistant/components/assist_pipeline/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/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index db6c517a81a..31b3b0d4e32 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["webrtc-noise-gain==1.2.2"] + "requirements": ["webrtc-noise-gain==1.2.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 678195986e4..83e7f7d45b9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 -webrtc-noise-gain==1.2.2 +webrtc-noise-gain==1.2.3 yarl==1.9.2 zeroconf==0.115.0 diff --git a/requirements_all.txt b/requirements_all.txt index c71a358fbd6..56ab3940ca7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2700,7 +2700,7 @@ waterfurnace==1.1.0 webexteamssdk==1.1.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.2.2 +webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1006ee60b0a..093b1cdc339 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,7 +2006,7 @@ wallbox==0.4.12 watchdog==2.3.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.2.2 +webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 From fff3c6c6e96434eaa27c009b89a25e6836c66f9e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 18:52:23 +0200 Subject: [PATCH 911/984] Bump aiowaqi to 1.1.0 (#99751) * Bump aiowaqi to 1.1.0 * Fix hassfest * Fix tests --- homeassistant/components/waqi/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/waqi/test_config_flow.py | 16 ++++++++-------- tests/components/waqi/test_sensor.py | 8 ++++---- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index bf31fb570a8..76e25225b7d 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", - "loggers": ["waqiasync"], - "requirements": ["aiowaqi==0.2.1"] + "loggers": ["aiowaqi"], + "requirements": ["aiowaqi==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 56ab3940ca7..413605ff7bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==0.2.1 +aiowaqi==1.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 093b1cdc339..fce934a8068 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==0.2.1 +aiowaqi==1.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index be738a119e5..7a95e000d82 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -57,7 +57,7 @@ async def test_full_map_flow( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -74,12 +74,12 @@ async def test_full_map_flow( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ), patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -133,7 +133,7 @@ async def test_flow_errors( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -150,7 +150,7 @@ async def test_flow_errors( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -220,7 +220,7 @@ async def test_error_in_second_step( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -251,12 +251,12 @@ async def test_error_in_second_step( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ), patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 18f77028a29..ef434bcc544 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -36,7 +36,7 @@ async def test_legacy_migration(hass: HomeAssistant) -> None: """Test migration from yaml to config flow.""" search_result_json = json.loads(load_fixture("waqi/search_result.json")) search_results = [ - WAQISearchResult.parse_obj(search_result) + WAQISearchResult.from_dict(search_result) for search_result in search_result_json ] with patch( @@ -44,7 +44,7 @@ async def test_legacy_migration(hass: HomeAssistant) -> None: return_value=search_results, ), patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -64,7 +64,7 @@ async def test_legacy_migration_already_imported( mock_config_entry.add_to_hass(hass) with patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -98,7 +98,7 @@ async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) - mock_config_entry.add_to_hass(hass) with patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): From d8f96d77093726b8642ce1f49b3c81655e5a31bf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 Sep 2023 20:05:38 +0200 Subject: [PATCH 912/984] Bumped version to 2023.10.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 0e659a58980..bfea6544b94 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 = 10 -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 8ed01169f89..11d2d7e54d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b1" +version = "2023.10.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2f6fefefa7d49ddf8e9cb547ebd965a20328b46d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 28 Sep 2023 20:48:07 +0200 Subject: [PATCH 913/984] Update frontend to 20230928.0 (#101067) --- 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 aa417b6e714..9f01fadb710 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==20230926.0"] + "requirements": ["home-assistant-frontend==20230928.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 83e7f7d45b9..13cc25cdf80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ ha-av==10.1.1 hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230926.0 +home-assistant-frontend==20230928.0 home-assistant-intents==2023.9.22 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 413605ff7bb..b29ac30cef2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -997,7 +997,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230926.0 +home-assistant-frontend==20230928.0 # homeassistant.components.conversation home-assistant-intents==2023.9.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fce934a8068..21087755a99 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -786,7 +786,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230926.0 +home-assistant-frontend==20230928.0 # homeassistant.components.conversation home-assistant-intents==2023.9.22 From 97448eff8f039aa7be47df3af8a1c737f87d1674 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 28 Sep 2023 13:38:33 -0700 Subject: [PATCH 914/984] Bump opower to 0.0.35 (#101072) --- 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 002495b9517..71fd841d0fc 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.34"] + "requirements": ["opower==0.0.35"] } diff --git a/requirements_all.txt b/requirements_all.txt index b29ac30cef2..c1017ea9324 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1383,7 +1383,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.34 +opower==0.0.35 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21087755a99..eaa9282bb39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1061,7 +1061,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.34 +opower==0.0.35 # homeassistant.components.oralb oralb-ble==0.17.6 From 9c0bc57fede85e1a2fd206ed8fe86b3c76a21262 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 28 Sep 2023 14:24:07 -0700 Subject: [PATCH 915/984] Add native precipitation unit for weatherkit (#101073) --- homeassistant/components/weatherkit/weather.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index 07745680b01..ce997fa500f 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -134,6 +134,7 @@ class WeatherKitWeather( _attr_native_pressure_unit = UnitOfPressure.MBAR _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_precipitation_unit = UnitOfLength.MILLIMETERS def __init__( self, From 85838c6af95c1e719c1599e15c063f59d2bcb7a5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 28 Sep 2023 15:30:43 -0500 Subject: [PATCH 916/984] Use wake word description if available (#101079) --- homeassistant/components/wyoming/wake_word.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index c9010425c52..d4cbd9b9263 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -46,7 +46,8 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): wake_service = service.info.wake[0] self._supported_wake_words = [ - wake_word.WakeWord(id=ww.name, name=ww.name) for ww in wake_service.models + wake_word.WakeWord(id=ww.name, name=ww.description or ww.name) + for ww in wake_service.models ] self._attr_name = wake_service.name self._attr_unique_id = f"{config_entry.entry_id}-wake_word" From bae33799385882bf5d010ae8cc43d945f10906bc Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 29 Sep 2023 07:05:26 +0200 Subject: [PATCH 917/984] Update xknxproject to 3.3.0 (#101081) --- 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 a915d886138..b5c98c7203a 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.2.0", + "xknxproject==3.3.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/requirements_all.txt b/requirements_all.txt index c1017ea9324..a0b33962ea4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2736,7 +2736,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.2.0 +xknxproject==3.3.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eaa9282bb39..153083fbcf3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2039,7 +2039,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.2.0 +xknxproject==3.3.0 # homeassistant.components.bluesound # homeassistant.components.fritz From 3f57c33f3219b4b9e7bcef611cec9a68e4ba297c Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 29 Sep 2023 03:56:17 +0200 Subject: [PATCH 918/984] Fix ZHA exception when writing `cie_addr` during configuration (#101087) Fix ZHA exception when writing `cie_addr` --- .../components/zha/core/cluster_handlers/security.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index f31830f0bd8..9c74a14daa8 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -369,12 +369,11 @@ class IASZoneClusterHandler(ClusterHandler): ieee = self.cluster.endpoint.device.application.state.node_info.ieee try: - res = await self.write_attributes_safe({"cie_addr": ieee}) + await self.write_attributes_safe({"cie_addr": ieee}) self.debug( - "wrote cie_addr: %s to '%s' cluster: %s", + "wrote cie_addr: %s to '%s' cluster", str(ieee), self._cluster.ep_attribute, - res[0], ) except HomeAssistantError as ex: self.debug( From ef3bd0100c0941c5d28bad73dcda9ec1483b06dd Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 29 Sep 2023 22:04:00 -0500 Subject: [PATCH 919/984] Bump plexapi to 4.15.3 (#101088) * Bump plexapi to 4.15.3 * Update tests for updated account endpoint * Update tests for updated resources endpoint * Switch to non-web client fixture * Set __qualname__ attribute for new library behavior --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plex/conftest.py | 10 +++- .../fixtures/player_plexhtpc_resources.xml | 3 ++ .../plex/fixtures/plextv_account.xml | 23 +++++---- .../fixtures/plextv_resources_one_server.xml | 40 +++++++++------- .../fixtures/plextv_resources_two_servers.xml | 48 +++++++++++-------- tests/components/plex/test_config_flow.py | 12 ++--- tests/components/plex/test_init.py | 10 ++-- tests/components/plex/test_media_players.py | 4 +- tests/components/plex/test_media_search.py | 11 ++++- tests/components/plex/test_playback.py | 40 ++++++++++++---- tests/components/plex/test_sensor.py | 30 ++++++++++-- tests/components/plex/test_services.py | 6 ++- 15 files changed, 159 insertions(+), 84 deletions(-) create mode 100644 tests/components/plex/fixtures/player_plexhtpc_resources.xml diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index bc0c54c49bf..6cf94793173 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.13.2", + "PlexAPI==4.15.3", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/requirements_all.txt b/requirements_all.txt index a0b33962ea4..1c3b3b58098 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -46,7 +46,7 @@ Mastodon.py==1.5.1 Pillow==10.0.0 # homeassistant.components.plex -PlexAPI==4.13.2 +PlexAPI==4.15.3 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 153083fbcf3..986828f170c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ HATasmota==0.7.3 Pillow==10.0.0 # homeassistant.components.plex -PlexAPI==4.13.2 +PlexAPI==4.15.3 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index e4bf61ccd94..78a3b7387ea 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -232,6 +232,12 @@ def player_plexweb_resources_fixture(): return load_fixture("plex/player_plexweb_resources.xml") +@pytest.fixture(name="player_plexhtpc_resources", scope="session") +def player_plexhtpc_resources_fixture(): + """Load resources payload for a Plex HTPC player and return it.""" + return load_fixture("plex/player_plexhtpc_resources.xml") + + @pytest.fixture(name="playlists", scope="session") def playlists_fixture(): """Load payload for all playlists and return it.""" @@ -450,8 +456,8 @@ def mock_plex_calls( """Mock Plex API calls.""" requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) requests_mock.get("https://plex.tv/api/invites/requested", text=empty_payload) - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/resources", text=plextv_resources) url = plex_server_url(entry) diff --git a/tests/components/plex/fixtures/player_plexhtpc_resources.xml b/tests/components/plex/fixtures/player_plexhtpc_resources.xml new file mode 100644 index 00000000000..6cc9cc0afbd --- /dev/null +++ b/tests/components/plex/fixtures/player_plexhtpc_resources.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/components/plex/fixtures/plextv_account.xml b/tests/components/plex/fixtures/plextv_account.xml index 32d6eec7c2d..b47896de577 100644 --- a/tests/components/plex/fixtures/plextv_account.xml +++ b/tests/components/plex/fixtures/plextv_account.xml @@ -1,15 +1,18 @@ - - - + + + + + + + + + - - - - testuser - testuser@email.com - 2000-01-01 12:34:56 UTC - faketoken + + + + diff --git a/tests/components/plex/fixtures/plextv_resources_one_server.xml b/tests/components/plex/fixtures/plextv_resources_one_server.xml index ff2e458ff24..75b7e54b7e6 100644 --- a/tests/components/plex/fixtures/plextv_resources_one_server.xml +++ b/tests/components/plex/fixtures/plextv_resources_one_server.xml @@ -1,18 +1,22 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/components/plex/fixtures/plextv_resources_two_servers.xml b/tests/components/plex/fixtures/plextv_resources_two_servers.xml index 7da5df4c1df..f14b55fe161 100644 --- a/tests/components/plex/fixtures/plextv_resources_two_servers.xml +++ b/tests/components/plex/fixtures/plextv_resources_two_servers.xml @@ -1,21 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index beb454e2e9c..235596715f4 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -143,7 +143,7 @@ async def test_no_servers_found( current_request_with_host: None, ) -> None: """Test when no servers are on an account.""" - requests_mock.get("https://plex.tv/api/resources", text=empty_payload) + requests_mock.get("https://plex.tv/api/v2/resources", text=empty_payload) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -225,7 +225,7 @@ async def test_multiple_servers_with_selection( assert result["step_id"] == "user" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -289,7 +289,7 @@ async def test_adding_last_unconfigured_server( assert result["step_id"] == "user" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) @@ -346,9 +346,9 @@ async def test_all_available_servers_configured( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - requests_mock.get("https://plex.tv/users/account", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) @@ -776,7 +776,7 @@ async def test_reauth_multiple_servers_available( ) -> None: """Test setup and reauthorization of a Plex token when multiple servers are available.""" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index bc43a1e0d89..6e1043b5c52 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -231,7 +231,7 @@ async def test_setup_when_certificate_changed( # Test with account failure requests_mock.get( - "https://plex.tv/users/account", status_code=HTTPStatus.UNAUTHORIZED + "https://plex.tv/api/v2/user", status_code=HTTPStatus.UNAUTHORIZED ) old_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(old_entry.entry_id) is False @@ -241,8 +241,8 @@ async def test_setup_when_certificate_changed( await hass.config_entries.async_unload(old_entry.entry_id) # Test with no servers found - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.get("https://plex.tv/api/resources", text=empty_payload) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/resources", text=empty_payload) assert await hass.config_entries.async_setup(old_entry.entry_id) is False await hass.async_block_till_done() @@ -252,7 +252,7 @@ async def test_setup_when_certificate_changed( # Test with success new_url = PLEX_DIRECT_URL - requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) + requests_mock.get("https://plex.tv/api/v2/resources", text=plextv_resources) for resource_url in [new_url, "http://1.2.3.4:32400"]: requests_mock.get(resource_url, text=plex_server_default) requests_mock.get(f"{new_url}/accounts", text=plex_server_accounts) @@ -287,7 +287,7 @@ async def test_bad_token_with_tokenless_server( ) -> None: """Test setup with a bad token and a server with token auth disabled.""" requests_mock.get( - "https://plex.tv/users/account", status_code=HTTPStatus.UNAUTHORIZED + "https://plex.tv/api/v2/user", status_code=HTTPStatus.UNAUTHORIZED ) await setup_plex_server() diff --git a/tests/components/plex/test_media_players.py b/tests/components/plex/test_media_players.py index 27fea36e3b0..e9efc945f71 100644 --- a/tests/components/plex/test_media_players.py +++ b/tests/components/plex/test_media_players.py @@ -12,10 +12,10 @@ async def test_plex_tv_clients( entry, setup_plex_server, requests_mock: requests_mock.Mocker, - player_plexweb_resources, + player_plexhtpc_resources, ) -> None: """Test getting Plex clients from plex.tv.""" - requests_mock.get("/resources", text=player_plexweb_resources) + requests_mock.get("/resources", text=player_plexhtpc_resources) with patch("plexapi.myplex.MyPlexResource.connect", side_effect=NotFound): await setup_plex_server() diff --git a/tests/components/plex/test_media_search.py b/tests/components/plex/test_media_search.py index 0cc94134f1c..21b50724786 100644 --- a/tests/components/plex/test_media_search.py +++ b/tests/components/plex/test_media_search.py @@ -70,7 +70,10 @@ async def test_media_lookups( ) assert "Library 'Not a Library' not found in" in str(excinfo.value) - with patch("plexapi.library.LibrarySection.search") as search: + with patch( + "plexapi.library.LibrarySection.search", + __qualname__="search", + ) as search: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -261,7 +264,11 @@ async def test_media_lookups( with pytest.raises(MediaNotFound) as excinfo: payload = '{"library_name": "Movies", "title": "Not a Movie"}' - with patch("plexapi.library.LibrarySection.search", side_effect=BadRequest): + with patch( + "plexapi.library.LibrarySection.search", + side_effect=BadRequest, + __qualname__="search", + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index c9dba4e4aca..9ea684256c4 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -49,14 +49,14 @@ async def test_media_player_playback( setup_plex_server, requests_mock: requests_mock.Mocker, playqueue_created, - player_plexweb_resources, + player_plexhtpc_resources, ) -> None: """Test playing media on a Plex media_player.""" - requests_mock.get("http://1.2.3.5:32400/resources", text=player_plexweb_resources) + requests_mock.get("http://1.2.3.6:32400/resources", text=player_plexhtpc_resources) await setup_plex_server() - media_player = "media_player.plex_plex_web_chrome" + media_player = "media_player.plex_plex_htpc_for_mac_plex_htpc" requests_mock.post("/playqueues", text=playqueue_created) playmedia_mock = requests_mock.get( "/player/playback/playMedia", status_code=HTTPStatus.OK @@ -65,7 +65,9 @@ async def test_media_player_playback( # Test media lookup failure payload = '{"library_name": "Movies", "title": "Movie 1" }' with patch( - "plexapi.library.LibrarySection.search", return_value=None + "plexapi.library.LibrarySection.search", + return_value=None, + __qualname__="search", ), pytest.raises(HomeAssistantError) as excinfo: await hass.services.async_call( MP_DOMAIN, @@ -86,7 +88,11 @@ async def test_media_player_playback( # Test movie success movies = [movie1] - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -101,7 +107,11 @@ async def test_media_player_playback( # Test movie success with resume playmedia_mock.reset() - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -163,7 +173,11 @@ async def test_media_player_playback( # Test multiple choices with exact match playmedia_mock.reset() movies = [movie1, movie2] - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -181,7 +195,11 @@ async def test_media_player_playback( movies = [movie2, movie3] with pytest.raises(HomeAssistantError) as excinfo: payload = '{"library_name": "Movies", "title": "Movie" }' - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -197,7 +215,11 @@ async def test_media_player_playback( # Test multiple choices with allow_multiple movies = [movie1, movie2, movie3] - with patch("plexapi.library.LibrarySection.search", return_value=movies), patch( + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ), patch( "homeassistant.components.plex.server.PlexServer.create_playqueue" ) as mock_create_playqueue: await hass.services.async_call( diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 9c73bf9f915..5b9729792f4 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -129,7 +129,11 @@ async def test_library_sensor_values( ) media = [MockPlexTVEpisode()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") @@ -165,7 +169,11 @@ async def test_library_sensor_values( trigger_plex_update( mock_websocket, msgtype="status", payload=LIBRARY_UPDATE_PAYLOAD ) - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") @@ -200,7 +208,11 @@ async def test_library_sensor_values( ) media = [MockPlexMovie()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") @@ -210,7 +222,11 @@ async def test_library_sensor_values( # Test with clip media = [MockPlexClip()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): async_dispatcher_send( hass, PLEX_UPDATE_LIBRARY_SIGNAL.format(mock_plex_server.machine_identifier) ) @@ -236,7 +252,11 @@ async def test_library_sensor_values( ) media = [MockPlexMusic()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_music_sensor = hass.states.get("sensor.plex_server_1_library_music") diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index a74b3e91460..dfd02bb1d3f 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -190,7 +190,11 @@ async def test_lookup_media_for_other_integrations( assert result.shuffle # Test with media not found - with patch("plexapi.library.LibrarySection.search", return_value=None): + with patch( + "plexapi.library.LibrarySection.search", + return_value=None, + __qualname__="search", + ): with pytest.raises(HomeAssistantError) as excinfo: process_plex_payload(hass, MediaType.MUSIC, CONTENT_ID_BAD_MEDIA) assert f"No {MediaType.MUSIC} results in 'Music' for" in str(excinfo.value) From bfd727597272aad2af316dde7bf108d85f541c3f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 08:58:05 +0200 Subject: [PATCH 920/984] Update Home Assistant base image to 2023.09.0 (#101092) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index cc13a4e595f..f9e19f89e23 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.08.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.08.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.08.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.08.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.08.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.09.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.09.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.09.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.09.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.09.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From 73356ae2325ee6cc4cd4e35f5ddaef0ceec27b96 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 09:30:00 +0200 Subject: [PATCH 921/984] Use pep 503 compatible wheels index for builds (#101096) --- Dockerfile | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index e229f27cb33..f2a365b2b8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,8 @@ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ RUN \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ @@ -39,9 +38,8 @@ RUN \ MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core @@ -49,9 +47,8 @@ COPY . homeassistant/ RUN \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant From 2cc229ce420ca72f17fb8b46ed021f7eb074ce76 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:49:19 +0200 Subject: [PATCH 922/984] Fix circular dependency on homeassistant (#101099) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 13cc25cdf80..61b6de913d5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -173,3 +173,7 @@ pysnmp==1000000000.0.0 # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 + +# Circular dependency on homeassistant itself +# https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 +alexapy<1.27.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a8bc99d68fa..4291d2c6e2f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -175,6 +175,10 @@ pysnmp==1000000000.0.0 # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 + +# Circular dependency on homeassistant itself +# https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 +alexapy<1.27.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From 1d2c570a018b44b7b2db1b4fe5b231cb10d1190d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 12:58:29 +0200 Subject: [PATCH 923/984] Ignore binary distribution wheels for charset-normalizer (#101104) --- .github/workflows/wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6c3022b194b..85912623f61 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -186,7 +186,7 @@ jobs: 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" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +200,7 @@ jobs: 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" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +214,7 @@ jobs: 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" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" From 65c7b307202fa9a2a8e2cdc5a281cd92aff08d3e Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Sat, 30 Sep 2023 10:43:07 +0200 Subject: [PATCH 924/984] Stop the Home Assistant Core container by default (#101105) --- rootfs/etc/services.d/home-assistant/finish | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish index 057957a9c03..ae5b17e171a 100755 --- a/rootfs/etc/services.d/home-assistant/finish +++ b/rootfs/etc/services.d/home-assistant/finish @@ -18,13 +18,11 @@ elif [[ ${APP_EXIT_CODE} -eq ${SIGNAL_EXIT_CODE} ]]; then NEW_EXIT_CODE=$((128 + SIGNAL_NO)) echo ${NEW_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - - if [[ ${SIGNAL_NO} -eq ${SIGTERM} ]]; then - /run/s6/basedir/bin/halt - fi else bashio::log.info "Home Assistant Core service shutdown" echo ${APP_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - /run/s6/basedir/bin/halt fi + +# Make sure to stop the container +/run/s6/basedir/bin/halt From 124eda6906cc8b9d4b4aad6fe6cf94eced3b51ea Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 13:18:33 +0200 Subject: [PATCH 925/984] Correct binary ignore for charset-normalizer to charset_normalizer (#101106) --- .github/workflows/wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 85912623f61..25245795c56 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -186,7 +186,7 @@ jobs: 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" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +200,7 @@ jobs: 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" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +214,7 @@ jobs: 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" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" From c1ade85d655831eeefdcf46b97be388b39860800 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 14:43:03 +0200 Subject: [PATCH 926/984] Pin charset-normalizer in our package constraints (#101107) --- .github/workflows/wheels.yml | 6 +++--- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 25245795c56..85912623f61 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -186,7 +186,7 @@ jobs: 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" - skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +200,7 @@ jobs: 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" - skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +214,7 @@ jobs: 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" - skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 61b6de913d5..4f5868e2fff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -177,3 +177,8 @@ get-mac==1000000000.0.0 # Circular dependency on homeassistant itself # https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 alexapy<1.27.0 + +# We want to skip the binary wheels for the 'charset-normalizer' packages. +# They are build with mypyc, but causes issues with our wheel builder. +# In order to do so, we need to constrain the version. +charset-normalizer==3.2.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4291d2c6e2f..e87e8b16bcb 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -179,6 +179,11 @@ get-mac==1000000000.0.0 # Circular dependency on homeassistant itself # https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 alexapy<1.27.0 + +# We want to skip the binary wheels for the 'charset-normalizer' packages. +# They are build with mypyc, but causes issues with our wheel builder. +# In order to do so, we need to constrain the version. +charset-normalizer==3.2.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From d84d83a42ae1718cdc21212503a97483fe048df0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:30:35 +0200 Subject: [PATCH 927/984] Migrate WAQI unique id (#101112) * Migrate unique_id * Add docstring --- homeassistant/components/waqi/__init__.py | 16 +++++++++++++ homeassistant/components/waqi/sensor.py | 2 +- tests/components/waqi/test_sensor.py | 29 ++++++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index bc51a91364c..d3cf1af21a2 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import WAQIDataUpdateCoordinator @@ -17,6 +18,8 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up World Air Quality Index (WAQI) from a config entry.""" + await _migrate_unique_ids(hass, entry) + client = WAQIClient(session=async_get_clientsession(hass)) client.authenticate(entry.data[CONF_API_KEY]) @@ -35,3 +38,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def _migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Migrate pre-config flow unique ids.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + for reg_entry in registry_entries: + if isinstance(reg_entry.unique_id, int): + entity_registry.async_update_entity( + reg_entry.entity_id, new_unique_id=f"{reg_entry.unique_id}_air_quality" + ) diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 0ad295ca5af..62170b329f4 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -159,7 +159,7 @@ class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) self._attr_name = f"WAQI {self.coordinator.data.city.name}" - self._attr_unique_id = str(coordinator.data.station_id) + self._attr_unique_id = f"{coordinator.data.station_id}_air_quality" @property def native_value(self) -> int | None: diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index ef434bcc544..7feb37a1b09 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import patch from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState @@ -15,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import 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, load_fixture @@ -93,6 +94,32 @@ async def test_legacy_migration_already_imported( assert len(issue_registry.issues) == 1 +async def test_sensor_id_migration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test migrating unique id for original sensor.""" + mock_config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, 4584, config_entry=mock_config_entry + ) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + assert hass.states.get("sensor.waqi_4584") + assert hass.states.get("sensor.waqi_de_jongweg_utrecht") is None + assert entities[0].unique_id == "4584_air_quality" + + async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: """Test failed update.""" mock_config_entry.add_to_hass(hass) From d216fbddae5c859bfc9f19e3c7bc1e81d679c27a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:12:27 +0200 Subject: [PATCH 928/984] Add logging to media extractor to know the selected stream (#101117) --- homeassistant/components/media_extractor/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index c6f899c4909..89f0a11ba61 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -153,7 +153,7 @@ class MediaExtractor: except MEQueryException: _LOGGER.error("Wrong query format: %s", stream_query) return - + _LOGGER.debug("Selected the following stream: %s", stream_url) data = {k: v for k, v in self.call_data.items() if k != ATTR_ENTITY_ID} data[ATTR_MEDIA_CONTENT_ID] = stream_url From b5eb1586974a6f7c8d894c8a03b0aa06f52ad2e1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:09:18 +0200 Subject: [PATCH 929/984] Correct youtube stream selector in media extractor (#101119) --- .../components/media_extractor/__init__.py | 13 ++++++++++--- .../media_extractor/snapshots/test_init.ambr | 8 ++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 89f0a11ba61..328871cf78c 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -193,9 +193,16 @@ def get_best_stream(formats: list[dict[str, Any]]) -> str: def get_best_stream_youtube(formats: list[dict[str, Any]]) -> str: - """YouTube requests also include manifest files. + """YouTube responses also include files with only video or audio. - They don't have a filesize so we skip all formats without filesize. + So we filter on files with both audio and video codec. """ - return get_best_stream([format for format in formats if "filesize" in format]) + return get_best_stream( + [ + format + for format in formats + if format.get("acodec", "none") != "none" + and format.get("vcodec", "none") != "none" + ] + ) diff --git a/tests/components/media_extractor/snapshots/test_init.ambr b/tests/components/media_extractor/snapshots/test_init.ambr index 56162ca3040..d70c370b60c 100644 --- a/tests/components/media_extractor/snapshots/test_init.ambr +++ b/tests/components/media_extractor/snapshots/test_init.ambr @@ -6,7 +6,7 @@ ]), 'extra': dict({ }), - 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJ-5AjGgFTR1w-qObfMtwCvs07CU5OUDG7bsNqAXrZMxAiEA4pJO9wj-ZQTqFHg5OP2_XZIJbog8NvY8BVSwENMwJfM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb', + 'media_content_id': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D', 'media_content_type': 'VIDEO', }) # --- @@ -87,7 +87,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgT7VwysCFd3nXvaSSiJoVxkNj5jfMPSeitLsQmy_S1b4CIQDWFiZSIH3tV4hQRtHa9DbzdYL8RQpbKD_6aeNZ7t-3IA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l', + 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAO2IJciEtkI3PvYyVC_zkyo61I70wYJQXuGOMueeacrKAiA-UAdaJSlqqkfaa6QtqVnC_BJJZn7BXs85gh_fdbGoSg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D', 'media_content_type': 'VIDEO', }) # --- @@ -105,7 +105,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJ-5AjGgFTR1w-qObfMtwCvs07CU5OUDG7bsNqAXrZMxAiEA4pJO9wj-ZQTqFHg5OP2_XZIJbog8NvY8BVSwENMwJfM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb', + 'media_content_id': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D', 'media_content_type': 'VIDEO', }) # --- @@ -114,7 +114,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=243&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=104373&dur=9.009&lmt=1660945832037331&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMLnlCaLvJ2scyVr6qYrCp3rzn_Op9eerIVWyp62NXKIAiEAnswRfxH5KssHQAKETF2MPncVWX_eDgpTXBEHN589-Xo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D', + 'media_content_id': 'https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=18&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=9.055&lmt=1665508348849369&mt=1694796392&fvip=5&fexp=24007246&beids=24350017&c=ANDROID&txp=4438434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALn143d2vS16xd_ndXj_rB8QOeHSCHC9YxSeOaRMF9eWAiAaYxqrRyV5bREBHLPCrs8Wk8Msm3hJrj11OJc2RIEyzw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D', 'media_content_type': 'VIDEO', }) # --- From 730acb34f2c3ff8f0e3a08e15f0829e89697cff8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 19:40:13 +0200 Subject: [PATCH 930/984] Revert pin on AlexaPy (#101123) --- homeassistant/package_constraints.txt | 4 ---- script/gen_requirements_all.py | 4 ---- 2 files changed, 8 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4f5868e2fff..d6f923f0047 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -174,10 +174,6 @@ pysnmp==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 -# Circular dependency on homeassistant itself -# https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 -alexapy<1.27.0 - # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e87e8b16bcb..e27b681f998 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -176,10 +176,6 @@ pysnmp==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 -# Circular dependency on homeassistant itself -# https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 -alexapy<1.27.0 - # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. From 822af4d40df121430d465afaa041bf315a82cfdc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 30 Sep 2023 05:07:11 +0200 Subject: [PATCH 931/984] Return None when value is not known in OpenHardwareMonitor (#101127) * Return None when value is not known * Add to coverage --- .coveragerc | 1 + homeassistant/components/openhardwaremonitor/sensor.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.coveragerc b/.coveragerc index 2f899999f41..533fd8de18d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -898,6 +898,7 @@ omit = homeassistant/components/opengarage/cover.py homeassistant/components/opengarage/entity.py homeassistant/components/opengarage/sensor.py + homeassistant/components/openhardwaremonitor/sensor.py homeassistant/components/openhome/__init__.py homeassistant/components/openhome/const.py homeassistant/components/openhome/media_player.py diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 70dbbd38fc8..4206bc72c1d 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -79,6 +79,8 @@ class OpenHardwareMonitorDevice(SensorEntity): @property def native_value(self): """Return the state of the device.""" + if self.value == "-": + return None return self.value @property From af041d290024f4e6d31eef066286b59b8bbbe1ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 20:57:53 +0200 Subject: [PATCH 932/984] Bump aiowaqi to 1.1.1 (#101129) --- 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 76e25225b7d..7b6bd3b8592 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==1.1.0"] + "requirements": ["aiowaqi==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1c3b3b58098..57a632284fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==1.1.0 +aiowaqi==1.1.1 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 986828f170c..72431ace108 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==1.1.0 +aiowaqi==1.1.1 # homeassistant.components.watttime aiowatttime==0.1.1 From 01182e8a5c0d75040cbd6b87e386f1938620cca4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 29 Sep 2023 23:05:33 -0400 Subject: [PATCH 933/984] Fix zwave_js firmware update logic (#101143) * Fix zwave_js firmware update logic * add comment * tweak implementation for ssame outcome --- homeassistant/components/zwave_js/update.py | 12 ++++++++---- tests/components/zwave_js/test_update.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 3dedd8bf370..6efae29e46e 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -211,11 +211,15 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): return try: - available_firmware_updates = ( - await self.driver.controller.async_get_available_firmware_updates( - self.node, API_KEY_FIRMWARE_UPDATE_SERVICE + # Retrieve all firmware updates including non-stable ones but filter + # non-stable channels out + available_firmware_updates = [ + update + for update in await self.driver.controller.async_get_available_firmware_updates( + self.node, API_KEY_FIRMWARE_UPDATE_SERVICE, True ) - ) + if update.channel == "stable" + ] except FailedZWaveCommand as err: LOGGER.debug( "Failed to get firmware updates for node %s: %s", diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 4c3aa9f5499..46dca7a35ec 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -87,6 +87,24 @@ FIRMWARE_UPDATES = { "rfRegion": 1, }, }, + # This firmware update should never show because it's in the beta channel + { + "version": "999.999.999", + "changelog": "blah 3", + "channel": "beta", + "files": [ + {"target": 0, "url": "https://example3.com", "integrity": "sha3"} + ], + "downgrade": True, + "normalizedVersion": "999.999.999", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, + }, ] } From 04829f0a1b57ff8c8b2dacd1b879737accd820b0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Sep 2023 10:54:01 +0200 Subject: [PATCH 934/984] Bumped version to 2023.10.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 bfea6544b94..e6a2c08d045 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 = 10 -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 11d2d7e54d6..ca5716c4fca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b2" +version = "2023.10.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 51069075712a07333925f29ba3bfbb966158a41c Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Sun, 1 Oct 2023 16:20:09 +0200 Subject: [PATCH 935/984] Terminology: Rename Multi-PAN to Multiprotocol to be consistent (#99262) --- .../silabs_multiprotocol_addon.py | 2 +- .../components/homeassistant_sky_connect/__init__.py | 2 +- .../homeassistant_sky_connect/config_flow.py | 2 +- .../components/homeassistant_yellow/__init__.py | 2 +- .../components/homeassistant_yellow/config_flow.py | 2 +- .../test_silabs_multiprotocol_addon.py | 12 ++++++------ .../homeassistant_sky_connect/test_init.py | 2 +- tests/components/homeassistant_yellow/test_init.py | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index c04575d8005..40cf1e18b0e 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -885,7 +885,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def check_multi_pan_addon(hass: HomeAssistant) -> None: - """Check the multi-PAN addon state, and start it if installed but not started. + """Check the multiprotocol addon state, and start it if installed but not started. Does nothing if Hass.io is not loaded. Raises on error or if the add-on is installed but not started. diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 5f17069f5d5..218e0c3e88d 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -45,7 +45,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: return hw_discovery_data = { - "name": "SkyConnect Multi-PAN", + "name": "SkyConnect Multiprotocol", "port": { "path": get_zigbee_socket(), }, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 5ac44f3f290..fce731777b1 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -76,7 +76,7 @@ class HomeAssistantSkyConnectOptionsFlow(silabs_multiprotocol_addon.OptionsFlowH def _zha_name(self) -> str: """Return the ZHA name.""" - return "SkyConnect Multi-PAN" + return "SkyConnect Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 30015d1bae4..b61e01061c3 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hw_discovery_data = ZHA_HW_DISCOVERY_DATA else: hw_discovery_data = { - "name": "Yellow Multi-PAN", + "name": "Yellow Multiprotocol", "port": { "path": get_zigbee_socket(), }, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 8be7b8a4ff7..667b8f3d97a 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -153,7 +153,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl def _zha_name(self) -> str: """Return the ZHA name.""" - return "Yellow Multi-PAN" + return "Yellow Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 17cd288050c..fbc77cdee9e 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -85,7 +85,7 @@ class FakeOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): def _zha_name(self) -> str: """Return the ZHA name.""" - return "Test Multi-PAN" + return "Test Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" @@ -353,7 +353,7 @@ async def test_option_flow_install_multi_pan_addon_zha( }, "radio_type": "ezsp", } - assert zha_config_entry.title == "Test Multi-PAN" + assert zha_config_entry.title == "Test Multiprotocol" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE @@ -663,7 +663,7 @@ async def test_option_flow_addon_installed_same_device_uninstall( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -928,7 +928,7 @@ async def test_option_flow_flasher_install_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -1071,7 +1071,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -1132,7 +1132,7 @@ async def test_option_flow_uninstall_migration_finish_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 3afc8c24774..e00603dc8f7 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -207,7 +207,7 @@ async def test_setup_zha_multipan( "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "SkyConnect Multi-PAN" + assert config_entry.title == "SkyConnect Multiprotocol" async def test_setup_zha_multipan_other_device( diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index a785e46c8b2..addc519c865 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -152,7 +152,7 @@ async def test_setup_zha_multipan( "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "Yellow Multi-PAN" + assert config_entry.title == "Yellow Multiprotocol" async def test_setup_zha_multipan_other_device( From c4d85ac41f6e278af41392a54c2812ed6fcb958e Mon Sep 17 00:00:00 2001 From: hlyi Date: Sun, 1 Oct 2023 06:21:26 -0500 Subject: [PATCH 936/984] Report unavailability for yolink sensor and binary_sensor (#100743) --- homeassistant/components/yolink/binary_sensor.py | 5 +++++ homeassistant/components/yolink/const.py | 1 + homeassistant/components/yolink/coordinator.py | 12 ++++++++++-- homeassistant/components/yolink/sensor.py | 5 +++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 38ea7d46537..e65896cdd42 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -136,3 +136,8 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): state.get(self.entity_description.state_key) ) self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true is device is available.""" + return super().available and self.coordinator.dev_online diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 935889a0368..9fc4dac8ada 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -8,3 +8,4 @@ ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" YOLINK_EVENT = f"{DOMAIN}_event" +YOLINK_OFFLINE_TIME = 32400 diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index 9055b2d044e..f2c942caab9 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import UTC, datetime, timedelta import logging from yolink.device import YoLinkDevice @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_DEVICE_STATE, DOMAIN +from .const import ATTR_DEVICE_STATE, DOMAIN, YOLINK_OFFLINE_TIME _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): ) self.device = device self.paired_device = paired_device + self.dev_online = True async def _async_update_data(self) -> dict: """Fetch device state.""" @@ -44,6 +45,13 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): async with asyncio.timeout(10): device_state_resp = await self.device.fetch_state() device_state = device_state_resp.data.get(ATTR_DEVICE_STATE) + device_reporttime = device_state_resp.data.get("reportAt") + if device_reporttime is not None: + rpt_time_delta = ( + datetime.now(tz=UTC).replace(tzinfo=None) + - datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ") + ).total_seconds() + self.dev_online = rpt_time_delta < YOLINK_OFFLINE_TIME if self.paired_device is not None and device_state is not None: paried_device_state_resp = await self.paired_device.fetch_state() paried_device_state = paried_device_state_resp.data.get( diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 451b486acd2..2fc4a2b0725 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -261,3 +261,8 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): return self._attr_native_value = attr_val self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true is device is available.""" + return super().available and self.coordinator.dev_online From 3941d2c8975ff74fa1a1071cd0e18cd57843acfa Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 1 Oct 2023 02:28:14 -0400 Subject: [PATCH 937/984] Bump zwave-js-server-python to 0.52.1 (#101162) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 3e8a5e4f757..505196c43eb 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.52.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 57a632284fc..10bcdc65afa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2817,7 +2817,7 @@ zigpy==0.57.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.0 +zwave-js-server-python==0.52.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72431ace108..a18e298a753 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2102,7 +2102,7 @@ zigpy-znp==0.11.5 zigpy==0.57.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.0 +zwave-js-server-python==0.52.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 531479bf5b3dd23d00e19de8c806f025d7df8b2d Mon Sep 17 00:00:00 2001 From: Tereza Tomcova Date: Sat, 30 Sep 2023 22:53:58 +0300 Subject: [PATCH 938/984] Bump PySwitchbot to 0.40.1 (#101164) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index e685d1de806..e835a2f4aca 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.40.0"] + "requirements": ["PySwitchbot==0.40.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 10bcdc65afa..4abe6e55e62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,7 +97,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.0 +PySwitchbot==0.40.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a18e298a753..baf890939d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.0 +PySwitchbot==0.40.1 # homeassistant.components.syncthru PySyncThru==0.7.10 From d7fa98454b4262ee3b3f9259b49c027afcdcdebf Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 1 Oct 2023 08:12:44 -0700 Subject: [PATCH 939/984] Fix rainbird entity unique ids (#101168) * Fix unique ids for rainbird entities * Update entity unique id use based on config entry entity id * Update tests/components/rainbird/test_binary_sensor.py Co-authored-by: Martin Hjelmare * Rename all entity_registry variables * Shorten long comment under line length limits --------- Co-authored-by: Martin Hjelmare --- .../components/rainbird/binary_sensor.py | 7 ++- homeassistant/components/rainbird/calendar.py | 17 ++++--- .../components/rainbird/coordinator.py | 27 +++++++---- homeassistant/components/rainbird/number.py | 7 ++- homeassistant/components/rainbird/sensor.py | 9 +++- homeassistant/components/rainbird/switch.py | 19 ++++---- tests/components/rainbird/conftest.py | 6 +-- .../components/rainbird/test_binary_sensor.py | 36 ++++++++++++++ tests/components/rainbird/test_calendar.py | 30 ++++++++++++ tests/components/rainbird/test_config_flow.py | 4 +- tests/components/rainbird/test_number.py | 34 +++++++++++++- tests/components/rainbird/test_sensor.py | 47 ++++++++++++++++++- tests/components/rainbird/test_switch.py | 32 +++++++++++++ 13 files changed, 237 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index b5886011ea3..3333d8bc4cb 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -48,8 +48,11 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorE """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rainsensor" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 4d8cc38c8bf..356f7d7cc4e 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -34,8 +34,9 @@ async def async_setup_entry( [ RainBirdCalendarEntity( data.schedule_coordinator, - data.coordinator.serial_number, + data.coordinator.unique_id, data.coordinator.device_info, + data.coordinator.device_name, ) ] ) @@ -47,20 +48,24 @@ class RainBirdCalendarEntity( """A calendar event entity.""" _attr_has_entity_name = True - _attr_name = None + _attr_name: str | None = None _attr_icon = "mdi:sprinkler" def __init__( self, coordinator: RainbirdScheduleUpdateCoordinator, - serial_number: str, - device_info: DeviceInfo, + unique_id: str | None, + device_info: DeviceInfo | None, + device_name: str, ) -> None: """Create the Calendar event device.""" super().__init__(coordinator) self._event: CalendarEvent | None = None - self._attr_unique_id = serial_number - self._attr_device_info = device_info + if unique_id: + self._attr_unique_id = unique_id + self._attr_device_info = device_info + else: + self._attr_name = device_name @property def event(self) -> CalendarEvent | None: diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 5c40ef808b2..763e50fe5d9 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER, TIMEOUT_SECONDS +from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) # The calendar data requires RPCs for each program/zone, and the data rarely @@ -51,7 +51,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): hass: HomeAssistant, name: str, controller: AsyncRainbirdController, - serial_number: str, + unique_id: str | None, model_info: ModelAndVersion, ) -> None: """Initialize RainbirdUpdateCoordinator.""" @@ -62,7 +62,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): update_interval=UPDATE_INTERVAL, ) self._controller = controller - self._serial_number = serial_number + self._unique_id = unique_id self._zones: set[int] | None = None self._model_info = model_info @@ -72,16 +72,23 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): return self._controller @property - def serial_number(self) -> str: - """Return the device serial number.""" - return self._serial_number + def unique_id(self) -> str | None: + """Return the config entry unique id.""" + return self._unique_id @property - def device_info(self) -> DeviceInfo: + def device_name(self) -> str: + """Device name for the rainbird controller.""" + return f"{MANUFACTURER} Controller" + + @property + def device_info(self) -> DeviceInfo | None: """Return information about the device.""" + if not self._unique_id: + return None return DeviceInfo( - name=f"{MANUFACTURER} Controller", - identifiers={(DOMAIN, self._serial_number)}, + name=self.device_name, + identifiers={(DOMAIN, self._unique_id)}, manufacturer=MANUFACTURER, model=self._model_info.model_name, sw_version=f"{self._model_info.major}.{self._model_info.minor}", @@ -164,7 +171,7 @@ class RainbirdData: self.hass, name=self.entry.title, controller=self.controller, - serial_number=self.entry.data[CONF_SERIAL_NUMBER], + unique_id=self.entry.unique_id, model_info=self.model_info, ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index d0945609a1b..1e72fabafcd 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -51,8 +51,11 @@ class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.serial_number}-rain-delay" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-rain-delay" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rain delay" @property def native_value(self) -> float | None: diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 32eb053f478..d44e7156cb5 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -52,8 +52,13 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity) """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = ( + f"{coordinator.device_name} {description.key.capitalize()}" + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index cafc541d860..62b3b0e9a8c 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -65,20 +65,23 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) self._zone = zone + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{zone}" + device_name = f"{MANUFACTURER} Sprinkler {zone}" if imported_name: self._attr_name = imported_name self._attr_has_entity_name = False else: - self._attr_name = None + self._attr_name = None if coordinator.unique_id else device_name self._attr_has_entity_name = True self._duration_minutes = duration_minutes - self._attr_unique_id = f"{coordinator.serial_number}-{zone}" - self._attr_device_info = DeviceInfo( - name=f"{MANUFACTURER} Sprinkler {zone}", - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer=MANUFACTURER, - via_device=(DOMAIN, coordinator.serial_number), - ) + if coordinator.unique_id and self._attr_unique_id: + self._attr_device_info = DeviceInfo( + name=device_name, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, coordinator.unique_id), + ) @property def extra_state_attributes(self): diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index dbc3456117c..f25bdfb1d86 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -86,7 +86,7 @@ def yaml_config() -> dict[str, Any]: @pytest.fixture -async def unique_id() -> str: +async def config_entry_unique_id() -> str: """Fixture for serial number used in the config entry.""" return SERIAL_NUMBER @@ -100,13 +100,13 @@ async def config_entry_data() -> dict[str, Any]: @pytest.fixture async def config_entry( config_entry_data: dict[str, Any] | None, - unique_id: str, + config_entry_unique_id: str | None, ) -> MockConfigEntry | None: """Fixture for MockConfigEntry.""" if config_entry_data is None: return None return MockConfigEntry( - unique_id=unique_id, + unique_id=config_entry_unique_id, domain=DOMAIN, data=config_entry_data, options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index cfa2c4d2684..e372a10ae23 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -5,6 +5,7 @@ import pytest 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, ComponentSetup @@ -25,6 +26,7 @@ async def test_rainsensor( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, expected_state: bool, ) -> None: """Test rainsensor binary sensor.""" @@ -38,3 +40,37 @@ async def test_rainsensor( "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 == "1263613994342-rainsensor" + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +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 ( + rainsensor.attributes.get("friendly_name") == "Rain Bird Controller Rainsensor" + ) + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert not entity_entry diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 2028fccc24f..2e486226a7b 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -14,6 +14,7 @@ import pytest 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 @@ -176,6 +177,7 @@ async def test_event_state( freezer: FrozenDateTimeFactory, freeze_time: datetime.datetime, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: """Test calendar upcoming event state.""" freezer.move_to(freeze_time) @@ -196,6 +198,10 @@ async def test_event_state( } assert state.state == expected_state + entity = entity_registry.async_get(TEST_ENTITY) + assert entity + assert entity.unique_id == 1263613994342 + @pytest.mark.parametrize( ("model_and_version_response", "has_entity"), @@ -270,3 +276,27 @@ async def test_program_schedule_disabled( "friendly_name": "Rain Bird Controller", "icon": "mdi:sprinkler", } + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +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" + + entity_entry = entity_registry.async_get(TEST_ENTITY) + assert not entity_entry diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index e7337ad6508..cfc4ff3b5cb 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -106,7 +106,7 @@ async def test_controller_flow( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", "expected_config_entry", @@ -154,7 +154,7 @@ async def test_multiple_config_entries( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", ), diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 6ce7d10c9f2..5d208f08a25 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( ACK_ECHO, @@ -39,8 +39,9 @@ async def test_number_values( hass: HomeAssistant, setup_integration: ComponentSetup, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor platform.""" + """Test number platform.""" assert await setup_integration() @@ -57,6 +58,10 @@ async def test_number_values( "unit_of_measurement": "d", } + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rain-delay" + async def test_set_value( hass: HomeAssistant, @@ -127,3 +132,28 @@ async def test_set_value_error( ) assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +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 ( + raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay" + ) + + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert not entity_entry diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 049a5f15c45..d8fb053c0ff 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -5,8 +5,9 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup +from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup @pytest.fixture @@ -22,6 +23,7 @@ def platforms() -> list[str]: async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, expected_state: str, ) -> None: """Test sensor platform.""" @@ -35,3 +37,46 @@ async def test_sensors( "friendly_name": "Rain Bird Controller Raindelay", "icon": "mdi:water-off", } + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-raindelay" + + +@pytest.mark.parametrize( + ("config_entry_unique_id", "config_entry_data"), + [ + # 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, + "serial_number": 0, + }, + ), + ], +) +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" + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert (entity_entry is None) == (config_entry_unique_id is None) diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 9ce5e799c92..46a875e8928 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -8,6 +8,7 @@ from homeassistant.components.rainbird import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import ( ACK_ECHO, @@ -57,6 +58,7 @@ 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.""" @@ -100,6 +102,10 @@ async def test_zones( assert not hass.states.get("switch.rain_bird_sprinkler_8") + # 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" + async def test_switch_on( hass: HomeAssistant, @@ -275,3 +281,29 @@ async def test_switch_error( with pytest.raises(HomeAssistantError, match=expected_msg): await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + None, + ], +) +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" + assert zone.state == "off" + + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry is None From b27097808dab66e2f08b970910efe49248c59487 Mon Sep 17 00:00:00 2001 From: Oliver <10700296+ol-iver@users.noreply.github.com> Date: Sun, 1 Oct 2023 12:26:28 +0200 Subject: [PATCH 940/984] Update denonavr to `0.11.4` (#101169) --- .../components/denonavr/manifest.json | 2 +- .../components/denonavr/media_player.py | 35 ++++++++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index b3c36ed39d2..0ba8caed6c5 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.3"], + "requirements": ["denonavr==0.11.4"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 51ede0d65b4..8b6907a60f7 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -8,7 +8,15 @@ import logging from typing import Any, Concatenate, ParamSpec, TypeVar from denonavr import DenonAVR -from denonavr.const import POWER_ON, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING +from denonavr.const import ( + ALL_TELNET_EVENTS, + ALL_ZONES, + POWER_ON, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, @@ -73,6 +81,23 @@ SERVICE_GET_COMMAND = "get_command" SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq" SERVICE_UPDATE_AUDYSSEY = "update_audyssey" +# HA Telnet events +TELNET_EVENTS = { + "HD", + "MS", + "MU", + "MV", + "NS", + "NSE", + "PS", + "SI", + "SS", + "TF", + "ZM", + "Z2", + "Z3", +} + _DenonDeviceT = TypeVar("_DenonDeviceT", bound="DenonDevice") _R = TypeVar("_R") _P = ParamSpec("_P") @@ -254,7 +279,9 @@ class DenonDevice(MediaPlayerEntity): async def _telnet_callback(self, zone, event, parameter) -> None: """Process a telnet command callback.""" # There are multiple checks implemented which reduce unnecessary updates of the ha state machine - if zone != self._receiver.zone: + if zone not in (self._receiver.zone, ALL_ZONES): + return + if event not in TELNET_EVENTS: return # Some updates trigger multiple events like one for artist and one for title for one change # We skip every event except the last one @@ -268,11 +295,11 @@ class DenonDevice(MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Register for telnet events.""" - self._receiver.register_callback("ALL", self._telnet_callback) + self._receiver.register_callback(ALL_TELNET_EVENTS, self._telnet_callback) async def async_will_remove_from_hass(self) -> None: """Clean up the entity.""" - self._receiver.unregister_callback("ALL", self._telnet_callback) + self._receiver.unregister_callback(ALL_TELNET_EVENTS, self._telnet_callback) @async_log_errors async def async_update(self) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 4abe6e55e62..4db254c5a73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.3 +denonavr==0.11.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baf890939d1..58ead8233e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -545,7 +545,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.3 +denonavr==0.11.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 From b20f9c40be0de434dd175853b27eba4a33a0becd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 1 Oct 2023 06:25:04 -0700 Subject: [PATCH 941/984] Clear calendar alarms after scheduling and add debug loggging (#101176) --- homeassistant/components/calendar/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 96872e039e1..1622f568a2d 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -531,6 +531,7 @@ class CalendarEntity(Entity): for unsub in self._alarm_unsubs: unsub() + self._alarm_unsubs.clear() now = dt_util.now() event = self.event @@ -540,6 +541,7 @@ class CalendarEntity(Entity): @callback def update(_: datetime.datetime) -> None: """Run when the active or upcoming event starts or ends.""" + _LOGGER.debug("Running %s update", self.entity_id) self._async_write_ha_state() if now < event.start_datetime_local: @@ -553,6 +555,13 @@ class CalendarEntity(Entity): self._alarm_unsubs.append( async_track_point_in_time(self.hass, update, event.end_datetime_local) ) + _LOGGER.debug( + "Scheduled %d updates for %s (%s, %s)", + len(self._alarm_unsubs), + self.entity_id, + event.start_datetime_local, + event.end_datetime_local, + ) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -561,6 +570,7 @@ class CalendarEntity(Entity): """ for unsub in self._alarm_unsubs: unsub() + self._alarm_unsubs.clear() async def async_get_events( self, From e0d7c1440b314c6efb2ce65b2863a7f06616f9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 1 Oct 2023 10:12:06 +0200 Subject: [PATCH 942/984] Update Mill library to 0.11.6 (#101180) --- 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 561a24c29df..cb0ba4522bf 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.5", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4db254c5a73..04ac8758390 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1222,7 +1222,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.5 +millheater==0.11.6 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58ead8233e2..9038e992c42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -948,7 +948,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.5 +millheater==0.11.6 # homeassistant.components.minio minio==7.1.12 From b24f09b47e337675b8480114a15407d7a7506738 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Oct 2023 18:29:53 +0200 Subject: [PATCH 943/984] Add config entry name to Withings webhook name (#101205) --- homeassistant/components/withings/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 44d32b0603c..aaef7bdb142 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -41,7 +41,14 @@ from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi -from .const import CONF_CLOUDHOOK_URL, CONF_PROFILES, CONF_USE_WEBHOOK, DOMAIN, LOGGER +from .const import ( + CONF_CLOUDHOOK_URL, + CONF_PROFILES, + CONF_USE_WEBHOOK, + DEFAULT_TITLE, + DOMAIN, + LOGGER, +) from .coordinator import WithingsDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -151,10 +158,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return + webhook_name = "Withings" + if entry.title != DEFAULT_TITLE: + webhook_name += " ".join([webhook_name, entry.title]) + webhook_register( hass, DOMAIN, - "Withings", + webhook_name, entry.data[CONF_WEBHOOK_ID], get_webhook_handler(coordinator), ) From cf6f0cf2665ff433c6cb31fa4b889ff94d71e51c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Oct 2023 18:28:53 +0200 Subject: [PATCH 944/984] Correct JSONDecodeError in co2signal (#101206) --- homeassistant/components/co2signal/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index c210d989c04..24d7bbd18af 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -3,11 +3,11 @@ from __future__ import annotations from collections.abc import Mapping from datetime import timedelta -from json import JSONDecodeError import logging from typing import Any, cast import CO2Signal +from requests.exceptions import JSONDecodeError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE From 8c84237e6b9185f7813d91a3f429d50e74b55945 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 1 Oct 2023 18:32:38 +0200 Subject: [PATCH 945/984] Bumped version to 2023.10.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 e6a2c08d045..d656032f32b 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 = 10 -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 ca5716c4fca..62e59dbe7cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b3" +version = "2023.10.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From cfa923252bd20a315a5072d7011399394e70d0fd Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 2 Oct 2023 14:15:54 +0200 Subject: [PATCH 946/984] Fix loop in progress config flow (#97229) * Fix data entry flow with multiple steps * Update a test * Update description and add a show progress change test --------- Co-authored-by: Martin Hjelmare --- homeassistant/data_entry_flow.py | 15 ++-- tests/test_data_entry_flow.py | 116 ++++++++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 63cbfda5b9b..e22d4229511 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -320,10 +320,17 @@ class FlowManager(abc.ABC): ) # If the result has changed from last result, fire event to update - # the frontend. - if ( - cur_step["step_id"] != result.get("step_id") - or result["type"] == FlowResultType.SHOW_PROGRESS + # the frontend. The result is considered to have changed if: + # - The step has changed + # - The step is same but result type is SHOW_PROGRESS and progress_action + # or description_placeholders has changed + if cur_step["step_id"] != result.get("step_id") or ( + result["type"] == FlowResultType.SHOW_PROGRESS + and ( + cur_step["progress_action"] != result.get("progress_action") + or cur_step["description_placeholders"] + != result.get("description_placeholders") + ) ): # Tell frontend to reload the flow state. self.hass.bus.async_fire( diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 168f97ba779..e6a28fc2e4f 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -344,14 +344,20 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: VERSION = 5 data = None task_one_done = False + task_two_done = False async def async_step_init(self, user_input=None): - if not user_input: - if not self.task_one_done: + if user_input and "task_finished" in user_input: + if user_input["task_finished"] == 1: self.task_one_done = True - progress_action = "task_one" - else: - progress_action = "task_two" + elif user_input["task_finished"] == 2: + self.task_two_done = True + + if not self.task_one_done: + progress_action = "task_one" + elif not self.task_two_done: + progress_action = "task_two" + if not self.task_one_done or not self.task_two_done: return self.async_show_progress( step_id="init", progress_action=progress_action, @@ -376,7 +382,7 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: # Mimic task one done and moving to task two # Called by integrations: `hass.config_entries.flow.async_configure(…)` - result = await manager.async_configure(result["flow_id"]) + result = await manager.async_configure(result["flow_id"], {"task_finished": 1}) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "task_two" @@ -388,13 +394,20 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: "refresh": True, } + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_two" + # Mimic task two done and continuing step # Called by integrations: `hass.config_entries.flow.async_configure(…)` - result = await manager.async_configure(result["flow_id"], {"title": "Hello"}) + result = await manager.async_configure( + result["flow_id"], {"task_finished": 2, "title": "Hello"} + ) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS_DONE await hass.async_block_till_done() - assert len(events) == 2 + assert len(events) == 2 # 1 for task one and 1 for task two assert events[1].data == { "handler": "test", "flow_id": result["flow_id"], @@ -407,6 +420,93 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: assert result["title"] == "Hello" +async def test_show_progress_fires_only_when_changed( + hass: HomeAssistant, manager +) -> None: + """Test show progress change logic.""" + manager.hass = hass + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + + async def async_step_init(self, user_input=None): + if user_input: + progress_action = user_input["progress_action"] + description_placeholders = user_input["description_placeholders"] + return self.async_show_progress( + step_id="init", + progress_action=progress_action, + description_placeholders=description_placeholders, + ) + return self.async_show_progress(step_id="init", progress_action="task_one") + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title=self.data["title"], data=self.data) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED + ) + + async def test_change( + flow_id, + events, + progress_action, + description_placeholders_progress, + number_of_events, + is_change, + ) -> None: + # Called by integrations: `hass.config_entries.flow.async_configure(…)` + result = await manager.async_configure( + flow_id, + { + "progress_action": progress_action, + "description_placeholders": { + "progress": description_placeholders_progress + }, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == progress_action + assert ( + result["description_placeholders"]["progress"] + == description_placeholders_progress + ) + + await hass.async_block_till_done() + assert len(events) == number_of_events + if is_change: + assert events[number_of_events - 1].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_one" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + # Mimic task one tests + await test_change( + result["flow_id"], events, "task_one", 0, 1, True + ) # change (progress action) + await test_change(result["flow_id"], events, "task_one", 0, 1, False) # no change + await test_change( + result["flow_id"], events, "task_one", 25, 2, True + ) # change (description placeholder) + await test_change( + result["flow_id"], events, "task_two", 50, 3, True + ) # change (progress action and description placeholder) + await test_change(result["flow_id"], events, "task_two", 50, 3, False) # no change + await test_change( + result["flow_id"], events, "task_two", 100, 4, True + ) # change (description placeholder) + + async def test_abort_flow_exception(manager) -> None: """Test that the AbortFlow exception works.""" From 93033e037d5efb71e7166311f1e4cd8ae8e91008 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 2 Oct 2023 14:11:16 -0400 Subject: [PATCH 947/984] Bump python-roborock to 0.34.6 (#101147) --- homeassistant/components/roborock/manifest.json | 2 +- homeassistant/components/roborock/strings.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/test_switch.py | 2 +- tests/components/roborock/test_time.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index dfd5a9ee1c7..6882754f49a 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.34.1"] + "requirements": ["python-roborock==0.34.6"] } diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index c46eb814151..53c536494f9 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -176,7 +176,8 @@ "moderate": "Moderate", "high": "High", "intense": "Intense", - "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]" + "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", + "custom_water_flow": "Custom water flow" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 04ac8758390..488857fb0d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2174,7 +2174,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.34.1 +python-roborock==0.34.6 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9038e992c42..79f35b36f74 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1618,7 +1618,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.34.1 +python-roborock==0.34.6 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 40ecdc267ed..fb301390fee 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -27,7 +27,7 @@ async def test_update_success( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + "homeassistant.components.roborock.coordinator.RoborockLocalClient._send_command" ) as mock_send_message: await hass.services.async_call( "switch", diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index 6ba996ca23f..1cf2fe6bed5 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -27,7 +27,7 @@ async def test_update_success( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + "homeassistant.components.roborock.coordinator.RoborockLocalClient._send_command" ) as mock_send_message: await hass.services.async_call( "time", From 98ca71fc966d1afe164c344ac64074e773f8ea84 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 2 Oct 2023 14:20:19 +0100 Subject: [PATCH 948/984] Split get users into chunks of 100 for Twitch sensors (#101211) * Split get users into chunks of 100 * Move to own function --- homeassistant/components/twitch/sensor.py | 26 +++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 11d6611ef99..05fd3fa3e71 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -52,6 +52,11 @@ STATE_OFFLINE = "offline" STATE_STREAMING = "streaming" +def chunk_list(lst: list, chunk_size: int) -> list[list]: + """Split a list into chunks of chunk_size.""" + return [lst[i : i + chunk_size] for i in range(0, len(lst), chunk_size)] + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -94,13 +99,20 @@ async def async_setup_entry( """Initialize entries.""" client = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - TwitchSensor(channel, client) - async for channel in client.get_users(logins=entry.options[CONF_CHANNELS]) - ], - True, - ) + channels = entry.options[CONF_CHANNELS] + + entities: list[TwitchSensor] = [] + + # Split channels into chunks of 100 to avoid hitting the rate limit + for chunk in chunk_list(channels, 100): + entities.extend( + [ + TwitchSensor(channel, client) + async for channel in client.get_users(logins=chunk) + ] + ) + + async_add_entities(entities, True) class TwitchSensor(SensorEntity): From bfe16e25365aae972117ab13a1334032c8a26377 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Oct 2023 19:33:38 +0100 Subject: [PATCH 949/984] Bump zeroconf to 0.115.1 (#101213) --- 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 9898c6a3496..53475588cfe 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.115.0"] + "requirements": ["zeroconf==0.115.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d6f923f0047..659caa1078d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.115.0 +zeroconf==0.115.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 488857fb0d9..3fea672d794 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2784,7 +2784,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.115.0 +zeroconf==0.115.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79f35b36f74..634d7dbb534 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2078,7 +2078,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.115.0 +zeroconf==0.115.1 # homeassistant.components.zeversolar zeversolar==0.3.1 From 18f3fb42c975d049d98ec79c1a3b08d488d82774 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Oct 2023 19:33:53 +0100 Subject: [PATCH 950/984] Bump aioesphomeapi to 17.0.1 (#101214) --- 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 d6fdd971fa6..8169eeb70e3 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==17.0.0", + "aioesphomeapi==17.0.1", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 3fea672d794..6f83e88ccdc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -231,7 +231,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.0.0 +aioesphomeapi==17.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 634d7dbb534..a255103e3bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.0.0 +aioesphomeapi==17.0.1 # homeassistant.components.flo aioflo==2021.11.0 From ced616fafa6fe729f87f39441bbe629915d7442d Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Mon, 2 Oct 2023 14:31:25 +0200 Subject: [PATCH 951/984] Remove invalid doc about multi origin/dest in google_travel_time (#101215) --- homeassistant/components/google_travel_time/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 78b84038c7f..270f8fe31e2 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "When specifying the origin and destination, you can supply one or more locations separated by the pipe character, in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", From ebf806111754d6584a7aa4b94e5066dd617b7101 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Oct 2023 20:55:00 +0200 Subject: [PATCH 952/984] Fix withings webhook name (#101221) --- homeassistant/components/withings/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index aaef7bdb142..246bcc134d0 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -160,7 +160,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: webhook_name = "Withings" if entry.title != DEFAULT_TITLE: - webhook_name += " ".join([webhook_name, entry.title]) + webhook_name = " ".join([DEFAULT_TITLE, entry.title]) webhook_register( hass, From e76396b1842bf7df9d7e3c7253f5299f65d7f121 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 2 Oct 2023 21:35:15 +1300 Subject: [PATCH 953/984] ESPHome: fix voice assistant default audio settings (#101241) --- homeassistant/components/esphome/voice_assistant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index baf3a9011e9..dc36b7475c4 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -222,7 +222,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): audio_settings: VoiceAssistantAudioSettings | None = None, ) -> None: """Run the Voice Assistant pipeline.""" - if audio_settings is None: + if audio_settings is None or audio_settings.volume_multiplier == 0: audio_settings = VoiceAssistantAudioSettings() tts_audio_output = ( From 63b5ba6b3a1299c03a368b87fb411dd949c1692e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Sep 2023 22:38:21 +0200 Subject: [PATCH 954/984] Remove dead code from broadlink light (#101063) --- homeassistant/components/broadlink/light.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index 796698c6a4c..57797ca592a 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -6,7 +6,6 @@ from broadlink.exceptions import BroadlinkException from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ColorMode, @@ -113,16 +112,6 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): state["colortemp"] = (color_temp - 153) * 100 + 2700 state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE - elif ATTR_COLOR_MODE in kwargs: - color_mode = kwargs[ATTR_COLOR_MODE] - if color_mode == ColorMode.HS: - state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB - elif color_mode == ColorMode.COLOR_TEMP: - state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE - else: - # Scenes are not yet supported. - state["bulb_colormode"] = BROADLINK_COLOR_MODE_SCENES - await self._async_set_state(state) async def async_turn_off(self, **kwargs: Any) -> None: From ad53ff037e533652a9782d451e4f90f3d246096f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Oct 2023 14:12:06 +0200 Subject: [PATCH 955/984] Fix color temperature setting in broadlink light (#101251) --- homeassistant/components/broadlink/light.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index 57797ca592a..fde6d322bc6 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -6,7 +6,7 @@ from broadlink.exceptions import BroadlinkException from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -45,6 +45,8 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): _attr_has_entity_name = True _attr_name = None + _attr_min_color_temp_kelvin = 2700 + _attr_max_color_temp_kelvin = 6500 def __init__(self, device): """Initialize the light.""" @@ -79,7 +81,7 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): self._attr_hs_color = [data["hue"], data["saturation"]] if "colortemp" in data: - self._attr_color_temp = round((data["colortemp"] - 2700) / 100 + 153) + self._attr_color_temp_kelvin = data["colortemp"] if "bulb_colormode" in data: if data["bulb_colormode"] == BROADLINK_COLOR_MODE_RGB: @@ -107,9 +109,9 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): state["saturation"] = int(hs_color[1]) state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB - elif ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] - state["colortemp"] = (color_temp - 153) * 100 + 2700 + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP_KELVIN] + state["colortemp"] = color_temp state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE await self._async_set_state(state) From 3cfae485778a56614ca6ebe7d650f7a869216df8 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 2 Oct 2023 12:56:39 +0100 Subject: [PATCH 956/984] Add extra validation in private_ble_device config flow (#101254) --- .../private_ble_device/config_flow.py | 33 ++++++++++++++----- .../private_ble_device/test_config_flow.py | 26 +++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py index 5bf130a0396..4fec68e507e 100644 --- a/homeassistant/components/private_ble_device/config_flow.py +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -19,6 +19,30 @@ _LOGGER = logging.getLogger(__name__) CONF_IRK = "irk" +def _parse_irk(irk: str) -> bytes | None: + if irk.startswith("irk:"): + irk = irk[4:] + + if irk.endswith("="): + try: + irk_bytes = bytes(reversed(base64.b64decode(irk))) + except binascii.Error: + # IRK is not valid base64 + return None + else: + try: + irk_bytes = binascii.unhexlify(irk) + except binascii.Error: + # IRK is not correctly hex encoded + return None + + if len(irk_bytes) != 16: + # IRK must be 16 bytes when decoded + return None + + return irk_bytes + + class BLEDeviceTrackerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for BLE Device Tracker.""" @@ -35,15 +59,8 @@ class BLEDeviceTrackerConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: irk = user_input[CONF_IRK] - if irk.startswith("irk:"): - irk = irk[4:] - if irk.endswith("="): - irk_bytes = bytes(reversed(base64.b64decode(irk))) - else: - irk_bytes = binascii.unhexlify(irk) - - if len(irk_bytes) != 16: + if not (irk_bytes := _parse_irk(irk)): errors[CONF_IRK] = "irk_not_valid" elif not (service_info := async_last_service_info(self.hass, irk_bytes)): errors[CONF_IRK] = "irk_not_found" diff --git a/tests/components/private_ble_device/test_config_flow.py b/tests/components/private_ble_device/test_config_flow.py index aa8ea0d905c..bb58cfedb29 100644 --- a/tests/components/private_ble_device/test_config_flow.py +++ b/tests/components/private_ble_device/test_config_flow.py @@ -42,6 +42,32 @@ async def test_invalid_irk(hass: HomeAssistant, enable_bluetooth: None) -> None: assert_form_error(result, "irk", "irk_not_valid") +async def test_invalid_irk_base64(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test invalid irk.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"irk": "Ucredacted4T8n!!ZZZ=="} + ) + assert_form_error(result, "irk", "irk_not_valid") + + +async def test_invalid_irk_hex(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test invalid irk.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"irk": "irk:abcdefghi"} + ) + assert_form_error(result, "irk", "irk_not_valid") + + async def test_irk_not_found(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test irk not found.""" result = await hass.config_entries.flow.async_init( From 9b810dcf9f9a5bf3645aad90344abe7843cfbe4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Oct 2023 13:07:56 +0200 Subject: [PATCH 957/984] Downgrade pylitterbot to 2023.4.5 (#101255) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index fd37365eb7d..9a3334cbaac 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.8"] + "requirements": ["pylitterbot==2023.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6f83e88ccdc..e53f8951f5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1830,7 +1830,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.8 +pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a255103e3bf..5822e33f24b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.8 +pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 From b069f92d953f7b79ee9e20ad3f9fd9120eeb0d19 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Oct 2023 13:01:26 +0200 Subject: [PATCH 958/984] Add missing device class to sensor.DEVICE_CLASS_UNITS (#101256) --- homeassistant/components/sensor/const.py | 1 + tests/components/number/test_init.py | 10 ++++++++++ tests/components/sensor/test_init.py | 13 +++++++++++++ 3 files changed, 24 insertions(+) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 139725ee1ab..e8b1742f315 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -542,6 +542,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { }, SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential), SensorDeviceClass.VOLUME: set(UnitOfVolume), + SensorDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), SensorDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 23758fe345d..3f612c421c8 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -901,3 +901,13 @@ async def test_name(hass: HomeAssistant) -> None: "mode": NumberMode.AUTO, "step": 1.0, } + + +def test_device_class_units(hass: HomeAssistant) -> None: + """Test all numeric device classes have unit.""" + # DEVICE_CLASS_UNITS should include all device classes except: + # - NumberDeviceClass.MONETARY + # - Device classes enumerated in NON_NUMERIC_DEVICE_CLASSES + assert set(NUMBER_DEVICE_CLASS_UNITS) == set( + NumberDeviceClass + ) - NON_NUMERIC_DEVICE_CLASSES - {NumberDeviceClass.MONETARY} diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 07d44207c68..01dfb9b3649 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, DOMAIN as SENSOR_DOMAIN, + NON_NUMERIC_DEVICE_CLASSES, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -2483,3 +2484,15 @@ def test_async_rounded_state_registered_entity_with_display_precision( hass.states.async_set(entity_id, "-0.0") state = hass.states.get(entity_id) assert async_rounded_state(hass, entity_id, state) == "0.0000" + + +def test_device_class_units_state_classes(hass: HomeAssistant) -> None: + """Test all numeric device classes have unit and state class.""" + # DEVICE_CLASS_UNITS should include all device classes except: + # - SensorDeviceClass.MONETARY + # - Device classes enumerated in NON_NUMERIC_DEVICE_CLASSES + assert set(DEVICE_CLASS_UNITS) == set( + SensorDeviceClass + ) - NON_NUMERIC_DEVICE_CLASSES - {SensorDeviceClass.MONETARY} + # DEVICE_CLASS_STATE_CLASSES should include all device classes + assert set(DEVICE_CLASS_STATE_CLASSES) == set(SensorDeviceClass) From 5370db4a3e3fd4cbaab9cca03d161f07d44c82f5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Oct 2023 13:49:22 +0200 Subject: [PATCH 959/984] Bump aiowaqi to 2.0.0 (#101259) --- 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 7b6bd3b8592..a866dc2c902 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==1.1.1"] + "requirements": ["aiowaqi==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e53f8951f5c..4d714f9b835 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==1.1.1 +aiowaqi==2.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5822e33f24b..136ec198853 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==1.1.1 +aiowaqi==2.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 From a0e5f016e1736d51c5f543989e21745874c8fedc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Oct 2023 14:30:33 +0200 Subject: [PATCH 960/984] Add documentation URL for the Home Assistant Green (#101263) --- homeassistant/components/homeassistant_green/hardware.py | 3 ++- tests/components/homeassistant_green/test_hardware.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_green/hardware.py b/homeassistant/components/homeassistant_green/hardware.py index 2b5268f8d03..c7b1641c09c 100644 --- a/homeassistant/components/homeassistant_green/hardware.py +++ b/homeassistant/components/homeassistant_green/hardware.py @@ -9,6 +9,7 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAME = "Home Assistant Green" +DOCUMENTATION_URL = "https://green.home-assistant.io/documentation/" MANUFACTURER = "homeassistant" MODEL = "green" @@ -39,6 +40,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: config_entries=config_entries, dongle=None, name=BOARD_NAME, - url=None, + url=DOCUMENTATION_URL, ) ] diff --git a/tests/components/homeassistant_green/test_hardware.py b/tests/components/homeassistant_green/test_hardware.py index 8aacf09978d..0221bf3a577 100644 --- a/tests/components/homeassistant_green/test_hardware.py +++ b/tests/components/homeassistant_green/test_hardware.py @@ -54,7 +54,7 @@ async def test_hardware_info( "config_entries": [config_entry.entry_id], "dongle": None, "name": "Home Assistant Green", - "url": None, + "url": "https://green.home-assistant.io/documentation/", } ] } From bad9b1c95f203ec9ef052d38f0d547259fb38791 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 2 Oct 2023 11:03:53 -0400 Subject: [PATCH 961/984] Bump python-myq to 3.1.11 (#101266) --- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 02bf454bc3e..5efcb8e1bb0 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["pkce", "pymyq"], - "requirements": ["python-myq==3.1.9"] + "requirements": ["python-myq==3.1.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4d714f9b835..11377c14cc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2149,7 +2149,7 @@ python-miio==0.5.12 python-mpd2==3.0.5 # homeassistant.components.myq -python-myq==3.1.9 +python-myq==3.1.11 # homeassistant.components.mystrom python-mystrom==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 136ec198853..9c2750f007b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1599,7 +1599,7 @@ python-matter-server==3.7.0 python-miio==0.5.12 # homeassistant.components.myq -python-myq==3.1.9 +python-myq==3.1.11 # homeassistant.components.mystrom python-mystrom==2.2.0 From 06d6122663f0173e2f20216f55d55ae37a3992e9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 2 Oct 2023 14:23:13 -0500 Subject: [PATCH 962/984] Bump intents to 2023.10.2 (#101277) --- 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 2f733ead486..f11dda15a4e 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.2.5", "home-assistant-intents==2023.9.22"] + "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 659caa1078d..db28fbc4ac6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 home-assistant-frontend==20230928.0 -home-assistant-intents==2023.9.22 +home-assistant-intents==2023.10.2 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 11377c14cc0..ac292c53434 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1000,7 +1000,7 @@ holidays==0.28 home-assistant-frontend==20230928.0 # homeassistant.components.conversation -home-assistant-intents==2023.9.22 +home-assistant-intents==2023.10.2 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c2750f007b..bd8e3c47874 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -789,7 +789,7 @@ holidays==0.28 home-assistant-frontend==20230928.0 # homeassistant.components.conversation -home-assistant-intents==2023.9.22 +home-assistant-intents==2023.10.2 # homeassistant.components.home_connect homeconnect==0.7.2 From 98d7945521f9530a85bf74b5f15fc1966426a052 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Oct 2023 19:10:15 +0200 Subject: [PATCH 963/984] Revert "Use shorthand attributes in Telldus live" (#101281) --- homeassistant/components/tellduslive/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 06b505d9574..e15f89888b1 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -142,7 +142,6 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): def __init__(self, client, device_id): """Initialize TelldusLiveSensor.""" super().__init__(client, device_id) - self._attr_unique_id = "{}-{}-{}".format(*device_id) if desc := SENSOR_TYPES.get(self._type): self.entity_description = desc else: @@ -190,3 +189,8 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): if self._type == SENSOR_TYPE_LUMINANCE: return self._value_as_luminance return self._value + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "{}-{}-{}".format(*self._id) From 791293ca87d073706146b3d68659e6fbec6f6b60 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 2 Oct 2023 12:44:47 -0600 Subject: [PATCH 964/984] Bump pylitterbot to 2023.4.9 (#101285) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 9a3334cbaac..ea096a908fc 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.5"] + "requirements": ["pylitterbot==2023.4.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac292c53434..c812251cf89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1830,7 +1830,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.5 +pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd8e3c47874..8da274571a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.5 +pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 From 9834c1de9aadc2d6951d5d7ecb85344abdc1c04b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Oct 2023 21:34:50 +0200 Subject: [PATCH 965/984] Bumped version to 2023.10.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 d656032f32b..d380645df3c 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 = 10 -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 62e59dbe7cc..b85b6da35cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b4" +version = "2023.10.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 0e29ccf06978de22f8eded6518cbb33edb42a744 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 2 Oct 2023 22:56:50 +0200 Subject: [PATCH 966/984] Update frontend to 20231002.0 (#101294) --- 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 9f01fadb710..40339e955f9 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==20230928.0"] + "requirements": ["home-assistant-frontend==20231002.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index db28fbc4ac6..eaba1eb6508 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ ha-av==10.1.1 hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230928.0 +home-assistant-frontend==20231002.0 home-assistant-intents==2023.10.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c812251cf89..8f920edc0ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -997,7 +997,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230928.0 +home-assistant-frontend==20231002.0 # homeassistant.components.conversation home-assistant-intents==2023.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8da274571a3..d068412ef8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -786,7 +786,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230928.0 +home-assistant-frontend==20231002.0 # homeassistant.components.conversation home-assistant-intents==2023.10.2 From be32db70a01c33b6f1ceb60d8eedac1411dbc9b1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Oct 2023 23:01:30 +0200 Subject: [PATCH 967/984] Update Lokalise CLI to v2.6.8 (#101297) --- script/translations/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/translations/const.py b/script/translations/const.py index 7c50b7db5e3..ef8e3f2df74 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -3,6 +3,6 @@ import pathlib CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "2.5.1" +CLI_2_DOCKER_IMAGE = "v2.6.8" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") From a9bc380c32c439909f18c50347ae198b9f737de8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Oct 2023 23:03:02 +0200 Subject: [PATCH 968/984] Bumped version to 2023.10.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 d380645df3c..c490d09028d 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 = 10 -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 b85b6da35cd..95ef4b80d46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b5" +version = "2023.10.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fd6eb614894abc63a7035d5fb7bbd68579d50f59 Mon Sep 17 00:00:00 2001 From: Aaron Collins Date: Tue, 3 Oct 2023 21:11:21 +1300 Subject: [PATCH 969/984] Remove duplicated device before daikin migration (#99900) Co-authored-by: Erik Montnemery --- homeassistant/components/daikin/__init__.py | 31 +++++- tests/components/daikin/test_init.py | 105 +++++++++++++++++++- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index f6fd399f855..eda7976e572 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -135,9 +135,11 @@ async def async_migrate_unique_id( ) -> None: """Migrate old entry.""" dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) old_unique_id = config_entry.unique_id new_unique_id = api.device.mac - new_name = api.device.values.get("name") + new_mac = dr.format_mac(new_unique_id) + new_name = api.name @callback def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: @@ -147,15 +149,36 @@ async def async_migrate_unique_id( if new_unique_id == old_unique_id: return + duplicate = dev_reg.async_get_device( + connections={(CONNECTION_NETWORK_MAC, new_mac)}, identifiers=None + ) + + # Remove duplicated device + if duplicate is not None: + if config_entry.entry_id in duplicate.config_entries: + _LOGGER.debug( + "Removing duplicated device %s", + duplicate.name, + ) + + # The automatic cleanup in entity registry is scheduled as a task, remove + # the entities manually to avoid unique_id collision when the entities + # are migrated. + duplicate_entities = er.async_entries_for_device( + ent_reg, duplicate.id, True + ) + for entity in duplicate_entities: + ent_reg.async_remove(entity.entity_id) + + dev_reg.async_remove_device(duplicate.id) + # Migrate devices for device_entry in dr.async_entries_for_config_entry( dev_reg, config_entry.entry_id ): for connection in device_entry.connections: if connection[1] == old_unique_id: - new_connections = { - (CONNECTION_NETWORK_MAC, dr.format_mac(new_unique_id)) - } + new_connections = {(CONNECTION_NETWORK_MAC, new_mac)} _LOGGER.debug( "Migrating device %s connections to %s", diff --git a/tests/components/daikin/test_init.py b/tests/components/daikin/test_init.py index a6a58b4fb39..3b5f81ae2e5 100644 --- a/tests/components/daikin/test_init.py +++ b/tests/components/daikin/test_init.py @@ -1,11 +1,13 @@ """Define tests for the Daikin init.""" import asyncio +from datetime import timedelta from unittest.mock import AsyncMock, PropertyMock, patch from aiohttp import ClientConnectionError +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.daikin import update_unique_id +from homeassistant.components.daikin import DaikinApi, update_unique_id from homeassistant.components.daikin.const import DOMAIN, KEY_MAC from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST @@ -14,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .test_config_flow import HOST, MAC -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -28,6 +30,7 @@ def mock_daikin(): with patch("homeassistant.components.daikin.Appliance") as Appliance: Appliance.factory.side_effect = mock_daikin_factory type(Appliance).update_status = AsyncMock() + type(Appliance).device_ip = PropertyMock(return_value=HOST) type(Appliance).inside_temperature = PropertyMock(return_value=22) type(Appliance).target_temperature = PropertyMock(return_value=22) type(Appliance).zones = PropertyMock(return_value=[("Zone 1", "0", 0)]) @@ -47,6 +50,67 @@ DATA = { INVALID_DATA = {**DATA, "name": None, "mac": HOST} +async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: + """Test duplicate device removal.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HOST, + title=None, + data={CONF_HOST: HOST, KEY_MAC: HOST}, + ) + config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + type(mock_daikin).mac = PropertyMock(return_value=HOST) + type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA) + + with patch( + "homeassistant.components.daikin.async_migrate_unique_id", return_value=None + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.unique_id != MAC + + type(mock_daikin).mac = PropertyMock(return_value=MAC) + type(mock_daikin).values = PropertyMock(return_value=DATA) + + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name + == "DaikinAP00000" + ) + + assert device_registry.async_get_device({}, {(KEY_MAC, HOST)}).name is None + + assert entity_registry.async_get("climate.daikin_127_0_0_1").unique_id == HOST + assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith( + HOST + ) + + assert entity_registry.async_get("climate.daikinap00000").unique_id == MAC + assert entity_registry.async_get( + "switch.daikinap00000_zone_1" + ).unique_id.startswith(MAC) + + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name == "DaikinAP00000" + ) + + assert entity_registry.async_get("climate.daikinap00000") is None + assert entity_registry.async_get("switch.daikinap00000_zone_1") is None + + assert entity_registry.async_get("climate.daikin_127_0_0_1").unique_id == MAC + assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) + + async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: """Test unique id migration.""" config_entry = MockConfigEntry( @@ -97,8 +161,41 @@ async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) +async def test_client_update_connection_error( + hass: HomeAssistant, mock_daikin, freezer: FrozenDateTimeFactory +) -> None: + """Test client connection error on update.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MAC, + data={CONF_HOST: HOST, KEY_MAC: MAC}, + ) + config_entry.add_to_hass(hass) + er.async_get(hass) + + type(mock_daikin).mac = PropertyMock(return_value=MAC) + type(mock_daikin).values = PropertyMock(return_value=DATA) + + await hass.config_entries.async_setup(config_entry.entry_id) + + api: DaikinApi = hass.data[DOMAIN][config_entry.entry_id] + + assert api.available is True + + type(mock_daikin).update_status.side_effect = ClientConnectionError + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + + await hass.async_block_till_done() + + assert api.available is False + + assert mock_daikin.update_status.call_count == 2 + + async def test_client_connection_error(hass: HomeAssistant, mock_daikin) -> None: - """Test unique id migration.""" + """Test client connection error on setup.""" config_entry = MockConfigEntry( domain=DOMAIN, unique_id=MAC, @@ -114,7 +211,7 @@ async def test_client_connection_error(hass: HomeAssistant, mock_daikin) -> None async def test_timeout_error(hass: HomeAssistant, mock_daikin) -> None: - """Test unique id migration.""" + """Test timeout error on setup.""" config_entry = MockConfigEntry( domain=DOMAIN, unique_id=MAC, From e0cbbf7d57be1d95c0ecf539e3d8b80493460825 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 3 Oct 2023 19:52:01 +1000 Subject: [PATCH 970/984] Revert PR #99077 for Aussie Broadband (#101314) --- .../components/aussie_broadband/__init__.py | 20 ++--------------- .../components/aussie_broadband/test_init.py | 22 ------------------- 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index 1bdb0579976..6fc4a4dd4d1 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -3,11 +3,10 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any from aiohttp import ClientError from aussiebb.asyncio import AussieBB -from aussiebb.const import FETCH_TYPES, NBN_TYPES, PHONE_TYPES +from aussiebb.const import FETCH_TYPES from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType from homeassistant.config_entries import ConfigEntry @@ -23,19 +22,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -# Backport for the pyaussiebb=0.0.15 validate_service_type method -def validate_service_type(service: dict[str, Any]) -> None: - """Check the service types against known types.""" - - if "type" not in service: - raise ValueError("Field 'type' not found in service data") - if service["type"] not in NBN_TYPES + PHONE_TYPES + ["Hardware"]: - raise UnrecognisedServiceType( - f"Service type {service['type']=} {service['name']=} - not recognised - ", - "please report this at https://github.com/yaleman/aussiebb/issues/new", - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Aussie Broadband from a config entry.""" # Login to the Aussie Broadband API and retrieve the current service list @@ -44,9 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], async_get_clientsession(hass), ) - # Overwrite the pyaussiebb=0.0.15 validate_service_type method with backport - # Required until pydantic 2.x is supported - client.validate_service_type = validate_service_type + try: await client.login() services = await client.get_services(drop_types=FETCH_TYPES) diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index dc32212ee87..3eb1972011c 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -3,11 +3,8 @@ from unittest.mock import patch from aiohttp import ClientConnectionError from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType -import pydantic -import pytest from homeassistant import data_entry_flow -from homeassistant.components.aussie_broadband import validate_service_type from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -22,19 +19,6 @@ async def test_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED -async def test_validate_service_type() -> None: - """Testing the validation function.""" - test_service = {"type": "Hardware", "name": "test service"} - validate_service_type(test_service) - - with pytest.raises(ValueError): - test_service = {"name": "test service"} - validate_service_type(test_service) - with pytest.raises(UnrecognisedServiceType): - test_service = {"type": "FunkyBob", "name": "test service"} - validate_service_type(test_service) - - async def test_auth_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" with patch( @@ -55,9 +39,3 @@ async def test_service_failure(hass: HomeAssistant) -> None: """Test init with a invalid service.""" entry = await setup_platform(hass, usage_effect=UnrecognisedServiceType()) assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_not_pydantic2() -> None: - """Test that Home Assistant still does not support Pydantic 2.""" - """For PR#99077 and validate_service_type backport""" - assert pydantic.__version__ < "2" From 9e4f9a88ad56a17c2cc53634fac14b0e7b391269 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 4 Oct 2023 00:35:26 +1000 Subject: [PATCH 971/984] Fix reference error in Aussie Broadband (#101315) --- homeassistant/components/aussie_broadband/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index 6fc4a4dd4d1..093480afd7d 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -45,10 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: return await client.get_usage(service_id) except UnrecognisedServiceType as err: - raise UpdateFailed( - f"Service {service_id} of type '{services[service_id]['type']}' was" - " unrecognised" - ) from err + raise UpdateFailed(f"Service {service_id} was unrecognised") from err return async_update_data From 38423ad6f1538eadf407f244c7d340ae7764aee7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 3 Oct 2023 12:17:23 +0200 Subject: [PATCH 972/984] Bump pyW800rf32 to 0.4 (#101317) bump pyW800rf32 from 0.3 to 0.4 --- homeassistant/components/w800rf32/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/w800rf32/manifest.json b/homeassistant/components/w800rf32/manifest.json index e76835abcbe..769eb96b3c0 100644 --- a/homeassistant/components/w800rf32/manifest.json +++ b/homeassistant/components/w800rf32/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/w800rf32", "iot_class": "local_push", "loggers": ["W800rf32"], - "requirements": ["pyW800rf32==0.1"] + "requirements": ["pyW800rf32==0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f920edc0ab..e370f6d64a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ pyTibber==0.28.2 pyW215==0.7.0 # homeassistant.components.w800rf32 -pyW800rf32==0.1 +pyW800rf32==0.4 # homeassistant.components.ads pyads==3.2.2 From 9c5d9344e2b38d123bfa2ba707a9448a71518b5b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 3 Oct 2023 12:41:00 -0500 Subject: [PATCH 973/984] Increase pipeline timeout to 5 minutes (#101327) --- .../assist_pipeline/websocket_api.py | 4 ++-- .../snapshots/test_websocket.ambr | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index f57424223cf..798843ea6e3 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -30,8 +30,8 @@ from .pipeline import ( async_get_pipeline, ) -DEFAULT_TIMEOUT = 30 -DEFAULT_WAKE_WORD_TIMEOUT = 3 +DEFAULT_TIMEOUT = 60 * 5 # seconds +DEFAULT_WAKE_WORD_TIMEOUT = 3 # seconds _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 044e7758eb2..7cecf9fed40 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -5,7 +5,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -86,7 +86,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -179,7 +179,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -359,7 +359,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -460,7 +460,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -491,7 +491,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -564,7 +564,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -590,7 +590,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -640,7 +640,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- From b9a929e63b0749393373099e04b7b932ea99a0e3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 3 Oct 2023 16:52:31 -0500 Subject: [PATCH 974/984] Pipeline runs are only equal with same id (#101341) * Pipeline runs are only equal with same id * Use dict instead of list in PipelineRuns * Let it blow up * Test * Test rest of __eq__ --- .../components/assist_pipeline/pipeline.py | 21 +++++++++----- tests/components/assist_pipeline/test_init.py | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 7e4c71671ad..76444fb2436 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -3,7 +3,7 @@ from __future__ import annotations import array import asyncio -from collections import deque +from collections import defaultdict, deque from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field from enum import StrEnum @@ -475,7 +475,7 @@ class PipelineRun: stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) tts_engine: str = field(init=False, repr=False) tts_options: dict | None = field(init=False, default=None) - wake_word_entity_id: str = field(init=False, repr=False) + wake_word_entity_id: str | None = field(init=False, default=None, repr=False) wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) abort_wake_word_detection: bool = field(init=False, default=False) @@ -518,6 +518,13 @@ class PipelineRun: self.audio_settings.noise_suppression_level, ) + def __eq__(self, other: Any) -> bool: + """Compare pipeline runs by id.""" + if isinstance(other, PipelineRun): + return self.id == other.id + + return False + @callback def process_event(self, event: PipelineEvent) -> None: """Log an event and call listener.""" @@ -1565,21 +1572,19 @@ class PipelineRuns: def __init__(self, pipeline_store: PipelineStorageCollection) -> None: """Initialize.""" - self._pipeline_runs: dict[str, list[PipelineRun]] = {} + self._pipeline_runs: dict[str, dict[str, PipelineRun]] = defaultdict(dict) self._pipeline_store = pipeline_store pipeline_store.async_add_listener(self._change_listener) def add_run(self, pipeline_run: PipelineRun) -> None: """Add pipeline run.""" pipeline_id = pipeline_run.pipeline.id - if pipeline_id not in self._pipeline_runs: - self._pipeline_runs[pipeline_id] = [] - self._pipeline_runs[pipeline_id].append(pipeline_run) + self._pipeline_runs[pipeline_id][pipeline_run.id] = pipeline_run def remove_run(self, pipeline_run: PipelineRun) -> None: """Remove pipeline run.""" pipeline_id = pipeline_run.pipeline.id - self._pipeline_runs[pipeline_id].remove(pipeline_run) + self._pipeline_runs[pipeline_id].pop(pipeline_run.id) async def _change_listener( self, change_type: str, item_id: str, change: dict @@ -1589,7 +1594,7 @@ class PipelineRuns: return if pipeline_runs := self._pipeline_runs.get(item_id): # Create a temporary list in case the list is modified while we iterate - for pipeline_run in list(pipeline_runs): + for pipeline_run in list(pipeline_runs.values()): pipeline_run.abort_wake_word_detection = True diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 5258736c89f..98ecae628f1 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -627,3 +627,32 @@ async def test_wake_word_detection_aborted( await pipeline_input.execute() assert process_events(events) == snapshot + + +def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: + """Test that pipeline run equality uses unique id.""" + + def event_callback(event): + pass + + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass) + run_1 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + run_2 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + + assert run_1 == run_1 + assert run_1 != run_2 + assert run_1 != 1234 From 776b26de3fddcdf019031a358765ac78813cdbf7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 4 Oct 2023 12:15:56 +1300 Subject: [PATCH 975/984] Fix manual stopping of the voice assistant pipeline (#101351) --- homeassistant/components/esphome/manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index f9f24128e2a..dfd7376f4f4 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -330,17 +330,17 @@ class ESPHomeManager: return None hass = self.hass - voice_assistant_udp_server = VoiceAssistantUDPServer( + self.voice_assistant_udp_server = VoiceAssistantUDPServer( hass, self.entry_data, self._handle_pipeline_event, self._handle_pipeline_finished, ) - port = await voice_assistant_udp_server.start_server() + port = await self.voice_assistant_udp_server.start_server() assert self.device_id is not None, "Device ID must be set" hass.async_create_background_task( - voice_assistant_udp_server.run_pipeline( + self.voice_assistant_udp_server.run_pipeline( device_id=self.device_id, conversation_id=conversation_id or None, flags=flags, From 937a26117c2a1bc27523d66a7829de655617ed38 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 4 Oct 2023 13:09:12 +1300 Subject: [PATCH 976/984] Allow esphome device to disable vad on stream (#101352) --- homeassistant/components/esphome/voice_assistant.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index dc36b7475c4..8fba4bfb39a 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -260,6 +260,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): noise_suppression_level=audio_settings.noise_suppression_level, auto_gain_dbfs=audio_settings.auto_gain, volume_multiplier=audio_settings.volume_multiplier, + is_vad_enabled=bool(flags & VoiceAssistantCommandFlag.USE_VAD), ), ) From 55ff8e1fcb77fcf9d824a121a003ba555e273ebc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Oct 2023 22:07:38 -0400 Subject: [PATCH 977/984] Bumped version to 2023.10.0b7 --- 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 c490d09028d..8af4f1175fb 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 = 10 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __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 95ef4b80d46..5abfa672a84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b6" +version = "2023.10.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9b9a16e9c64a9c05cb2b9f1d744af63fa13c0443 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 4 Oct 2023 18:05:44 +1000 Subject: [PATCH 978/984] Fix temperature when myZone is in use for Advantage air (#101316) --- homeassistant/components/advantage_air/climate.py | 7 +++++++ homeassistant/components/advantage_air/entity.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index fa9f609ba10..cda123f62ee 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -125,6 +125,13 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the current target temperature.""" + # If the system is in MyZone mode, and a zone is set, return that temperature instead. + if ( + self._ac["myZone"] > 0 + and not self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED) + and not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED) + ): + return self._myzone["setTemp"] return self._ac["setTemp"] @property diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index 00750fb4e94..b300a677793 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -62,6 +62,12 @@ class AdvantageAirAcEntity(AdvantageAirEntity): def _ac(self) -> dict[str, Any]: return self.coordinator.data["aircons"][self.ac_key]["info"] + @property + def _myzone(self) -> dict[str, Any]: + return self.coordinator.data["aircons"][self.ac_key]["zones"].get( + f"z{self._ac['myZone']:02}" + ) + class AdvantageAirZoneEntity(AdvantageAirAcEntity): """Parent class for Advantage Air Zone Entities.""" From 337f9197bb8fe7c714b45a26fe172388bf832891 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 4 Oct 2023 03:40:03 -0400 Subject: [PATCH 979/984] Check that dock error status is not None for Roborock (#101321) Co-authored-by: Robert Resch --- homeassistant/components/roborock/sensor.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 8a18c281d59..113e02e4abe 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import datetime from roborock.containers import ( RoborockDockErrorCode, @@ -38,7 +39,7 @@ from .device import RoborockCoordinatedEntity class RoborockSensorDescriptionMixin: """A class that describes sensor entities.""" - value_fn: Callable[[DeviceProp], int] + value_fn: Callable[[DeviceProp], StateType | datetime.datetime] @dataclass @@ -48,6 +49,15 @@ class RoborockSensorDescription( """A class that describes Roborock sensors.""" +def _dock_error_value_fn(properties: DeviceProp) -> str | None: + if ( + status := properties.status.dock_error_status + ) is not None and properties.status.dock_type != RoborockDockTypeCode.no_dock: + return status.name + + return None + + SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -173,9 +183,7 @@ SENSOR_DESCRIPTIONS = [ key="dock_error", icon="mdi:garage-open", translation_key="dock_error", - value_fn=lambda data: data.status.dock_error_status.name - if data.status.dock_type != RoborockDockTypeCode.no_dock - else None, + value_fn=_dock_error_value_fn, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=RoborockDockErrorCode.keys(), @@ -228,7 +236,7 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): self.entity_description = description @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime.datetime: """Return the value reported by the sensor.""" return self.entity_description.value_fn( self.coordinator.roborock_device_info.props From ebde9914f220e5431854782a2f76cc1dd2ec1b68 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 4 Oct 2023 09:19:57 +0200 Subject: [PATCH 980/984] Increase update interval of update platform in devolo_home_network (#101366) Increase update interval of firmware platform --- homeassistant/components/devolo_home_network/__init__.py | 3 ++- homeassistant/components/devolo_home_network/const.py | 1 + tests/components/devolo_home_network/test_update.py | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index d76a6163516..94e848fe8af 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -35,6 +35,7 @@ from .const import ( CONNECTED_PLC_DEVICES, CONNECTED_WIFI_CLIENTS, DOMAIN, + FIRMWARE_UPDATE_INTERVAL, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, REGULAR_FIRMWARE, @@ -146,7 +147,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER, name=REGULAR_FIRMWARE, update_method=async_update_firmware_available, - update_interval=LONG_UPDATE_INTERVAL, + update_interval=FIRMWARE_UPDATE_INTERVAL, ) if device.device and "wifi1" in device.device.features: coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator( diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index ba3f5e5b815..aaee8051cb5 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -14,6 +14,7 @@ PRODUCT = "product" SERIAL_NUMBER = "serial_number" TITLE = "title" +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=5) LONG_UPDATE_INTERVAL = timedelta(minutes=5) SHORT_UPDATE_INTERVAL = timedelta(seconds=15) diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 97d313d9273..cb6de649e8e 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.devolo_home_network.const import ( DOMAIN, - LONG_UPDATE_INTERVAL, + FIRMWARE_UPDATE_INTERVAL, ) from homeassistant.components.update import ( DOMAIN as PLATFORM, @@ -78,7 +78,7 @@ async def test_update_firmware( mock_device.device.async_check_firmware_available.return_value = ( UpdateFirmwareCheck(result=UPDATE_NOT_AVAILABLE) ) - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -106,7 +106,7 @@ async def test_device_failure_check( assert state is not None mock_device.device.async_check_firmware_available.side_effect = DeviceUnavailable - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() From 0470ca3e76b0d814500ec0b06246c3ba0a042d29 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Oct 2023 09:54:43 +0200 Subject: [PATCH 981/984] Update Pillow to 10.0.1 (#101368) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index bc7c7d97430..12397eb8990 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.0.0"] + "requirements": ["pydoods==1.0.2", "Pillow==10.0.1"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index a89ee370920..2966d668ac9 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.0.0"] + "requirements": ["ha-av==10.1.1", "Pillow==10.0.1"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 4f139785cd3..b6c74f0c53c 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 74bb97d10fc..69d059fdce5 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.0"] + "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.1"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index b38bc93567d..b5b25a66342 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 2176aa0c91e..f1f40dd8973 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.0.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==10.0.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index ed8638d8419..2b730648e22 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 33080a9c1a2..d1bc97da7a8 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.0.0", "simplehound==0.3"] + "requirements": ["Pillow==10.0.1", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index bfd3e77ee50..c8682941e28 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==1.26.0", - "Pillow==10.0.0" + "Pillow==10.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index eaba1eb6508..51d03a40971 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ mutagen==1.47.0 orjson==3.9.7 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==10.0.0 +Pillow==10.0.1 pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index e370f6d64a3..cbf8738cb2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -43,7 +43,7 @@ Mastodon.py==1.5.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.0 +Pillow==10.0.1 # homeassistant.components.plex PlexAPI==4.15.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d068412ef8e..083595f13aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.7.3 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.0 +Pillow==10.0.1 # homeassistant.components.plex PlexAPI==4.15.3 From 8e05df2b44cb946e126c3123486b8f3853c0e13f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Oct 2023 10:11:43 +0200 Subject: [PATCH 982/984] Bumped version to 2023.10.0b8 --- 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 8af4f1175fb..d9199dc035c 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 = 10 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __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 5abfa672a84..56d469272d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b7" +version = "2023.10.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 512b2af13cc84237129772750df4c12aad105e1a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Oct 2023 10:24:20 +0200 Subject: [PATCH 983/984] Bumped version to 2023.10.0b9 --- 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 d9199dc035c..41d1ad3449b 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 = 10 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __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 56d469272d8..0052c4999d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b8" +version = "2023.10.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 01daae69ab016f19da3dc62f1cb2dcad2dbacd4b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Oct 2023 13:48:40 +0200 Subject: [PATCH 984/984] Bumped version to 2023.10.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 41d1ad3449b..c027875eae1 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 = 10 -PATCH_VERSION: Final = "0b9" +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 0052c4999d0..9e153b6cc4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0b9" +version = "2023.10.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"